Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/main/columnPattern.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,32 @@ function compileRawRegex(pattern: string, flags: string): CompiledColumnPattern
return { regex: pattern, flags, fields: ['match'], named: false };
}

export interface DetectedSpan { start: number; end: number; name: string }

// Auto-detect column spans in a sample line: split on whitespace at bracket/quote depth 0,
// keeping [..], (..), {..}, <..>, "..", '..' groups whole. Each token becomes a column
// (col1, col2…). Simple + solid β€” the caller feeds these to compilePaint, which TYPES each
// token (date/int/word…); the user then names/tweaks in the Column Layout panel.
export function autoDetectSpans(line: string): DetectedSpan[] {
const spans: DetectedSpan[] = [];
const closerFor: Record<string, string> = { '[': ']', '(': ')', '{': '}', '<': '>' };
let start = -1;
let quote = '';
const stack: string[] = [];
const flush = (i: number) => { if (start >= 0) { spans.push({ start, end: i, name: `col${spans.length + 1}` }); start = -1; } };
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (quote) { if (ch === quote) quote = ''; continue; } // inside a quote β†’ keep going
if (ch === '"' || ch === "'") { if (start < 0) start = i; quote = ch; continue; }
if (closerFor[ch]) { if (start < 0) start = i; stack.push(closerFor[ch]); continue; }
if (stack.length && ch === stack[stack.length - 1]) { if (start < 0) start = i; stack.pop(); continue; }
if (/\s/.test(ch) && stack.length === 0) { flush(i); continue; } // whitespace at depth 0 β†’ split
if (start < 0) start = i;
}
flush(line.length);
return spans;
}

/** Compile any spec to a single named/numbered-capture regex + ordered fields. */
export function compileColumnPattern(spec: ColumnPatternSpec): CompiledColumnPattern {
const flags = spec.flags || '';
Expand Down
1 change: 1 addition & 0 deletions src/renderer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,7 @@ <h2>LOGAN</h2>
<button class="patcol-mode-btn" data-patcol-mode="regex">Regex</button>
</div>
<button id="btn-patcol-sample" class="secondary-btn small" title="Pull the current / selected line as the sample">↻ Sample</button>
<button id="btn-patcol-autodetect" class="secondary-btn small" title="Auto-analyse the sample line into typed columns β€” then rename/tweak">πŸ” Auto-detect columns</button>
<button id="btn-patcol-copy" class="secondary-btn small" title="Copy the compiled regex to the clipboard">⧉ Copy regex</button>
<button id="btn-patcol-save" class="secondary-btn small" title="Save this pattern for reuse across files">πŸ’Ύ Save…</button>
<button id="btn-patcol-apply" class="secondary-btn small" title="Render these columns in the viewer, and open them in the Columns window for show/hide">β–Έ Use as columns</button>
Expand Down
39 changes: 39 additions & 0 deletions src/renderer/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15652,6 +15652,44 @@ async function patcolPullSample(): Promise<void> {
} catch { /* ignore */ }
}

// MIRROR of autoDetectSpans() in src/main/columnPattern.ts (tested there) β€” keep in sync.
function autoDetectSpansLocal(line: string): PatcolSpan[] {
const spans: PatcolSpan[] = [];
const closerFor: Record<string, string> = { '[': ']', '(': ')', '{': '}', '<': '>' };
let start = -1, quote = '';
const stack: string[] = [];
const flush = (i: number) => { if (start >= 0) { spans.push({ start, end: i, name: `col${spans.length + 1}` }); start = -1; } };
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (quote) { if (ch === quote) quote = ''; continue; }
if (ch === '"' || ch === "'") { if (start < 0) start = i; quote = ch; continue; }
if (closerFor[ch]) { if (start < 0) start = i; stack.push(closerFor[ch]); continue; }
if (stack.length && ch === stack[stack.length - 1]) { if (start < 0) start = i; stack.pop(); continue; }
if (/\s/.test(ch) && stack.length === 0) { flush(i); continue; }
if (start < 0) start = i;
}
flush(line.length);
return spans;
}

// "πŸ” Auto-detect columns": analyse a sample line β†’ paint spans (one per token), which the
// tested compilePaint then TYPES (date/int/word…). The draft lands in the panel to name/tweak.
async function patcolAutoDetect(): Promise<void> {
const sampleInput = document.getElementById('patcol-sample-input') as HTMLInputElement | null;
if (!sampleInput || !state.filePath) return;
if (!sampleInput.value.trim()) {
const ln = state.selectedLine != null ? state.selectedLine : (state.visibleStartLine ?? 0);
try { const r = await window.api.getLines(ln, 1); const t = r?.lines?.[0]?.text || ''; if (t) sampleInput.value = t; } catch { /* ignore */ }
}
const line = sampleInput.value;
if (!line.trim()) { showToast('No sample line to analyze β€” put the cursor on a line first'); return; }
patcolSetMode('paint');
patcolSpans = autoDetectSpansLocal(line);
patcolRenderPaint();
patcolPreviewSoon();
showToast(`Auto-detected ${patcolSpans.length} columns β€” rename/adjust, then Use as columns`);
}

function patcolSetMode(mode: PatcolMode): void {
patcolMode = mode;
document.querySelectorAll('.patcol-mode-btn').forEach((b) => {
Expand Down Expand Up @@ -23249,6 +23287,7 @@ function init(): void {
if (mode) patcolSetMode(mode);
}));
document.getElementById('btn-patcol-sample')?.addEventListener('click', patcolPullSample);
document.getElementById('btn-patcol-autodetect')?.addEventListener('click', () => { void patcolAutoDetect(); });
document.getElementById('btn-patcol-copy')?.addEventListener('click', patcolCopyRegex);
document.getElementById('btn-patcol-save')?.addEventListener('click', patcolSavePattern);
document.getElementById('btn-patcol-apply')?.addEventListener('click', () => patcolUseAsColumns());
Expand Down
26 changes: 25 additions & 1 deletion src/tests/columnPattern.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { compileColumnPattern, makeColumnExtractor, refinePaintPattern, ColumnPatternSpec } from '../main/columnPattern';
import { compileColumnPattern, makeColumnExtractor, refinePaintPattern, autoDetectSpans, ColumnPatternSpec } from '../main/columnPattern';

function extract(spec: ColumnPatternSpec, line: string): string[] | null {
return makeColumnExtractor(compileColumnPattern(spec))(line);
Expand Down Expand Up @@ -142,3 +142,27 @@ describe('refinePaintPattern β€” refine from data (peel constant wrappers)', ()
expect(makeColumnExtractor(c)('foo -> ok')).toEqual(['foo']); // 1 space also matches (\\s+)
});
});

describe('autoDetectSpans β€” tokenise a line into column spans', () => {
const toks = (line: string) => autoDetectSpans(line).map(s => line.slice(s.start, s.end));

it('splits on whitespace and keeps [..] and quotes whole', () => {
expect(toks('2024-01-02 INFO [Auth Service] user=bob'))
.toEqual(['2024-01-02', 'INFO', '[Auth Service]', 'user=bob']);
expect(toks('a "hello world" b')).toEqual(['a', '"hello world"', 'b']);
});

it('names columns col1..colN and the spans feed compilePaint (typed)', () => {
const line = '2024-01-02 42';
const spans = autoDetectSpans(line);
expect(spans.map(s => s.name)).toEqual(['col1', 'col2']);
const c = compileColumnPattern({ mode: 'paint', sample: line, spans });
expect(c.regex).toContain('\\d{4}-\\d{2}-\\d{2}'); // date typed
expect(c.regex).toContain('-?\\d+'); // int typed
});

it('handles empty / whitespace-only lines', () => {
expect(autoDetectSpans('')).toEqual([]);
expect(autoDetectSpans(' ')).toEqual([]);
});
});
Loading