From 3b34171ea026e4eca9f424fcf0bc6040ff84d262 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Sat, 18 Jul 2026 07:11:02 +0500 Subject: [PATCH] refactor: combine frontmatter fields and block, add tests --- src/frontmatter/frontmatterBlock.ts | 148 ---------------------- src/frontmatter/frontmatterFields.test.ts | 53 ++++++++ src/frontmatter/frontmatterFields.ts | 141 +++++++++++++++++++-- src/frontmatter/parseTaskTags.ts | 2 +- src/frontmatter/validateFrontmatter.ts | 2 +- 5 files changed, 188 insertions(+), 158 deletions(-) delete mode 100644 src/frontmatter/frontmatterBlock.ts diff --git a/src/frontmatter/frontmatterBlock.ts b/src/frontmatter/frontmatterBlock.ts deleted file mode 100644 index 330567e..0000000 --- a/src/frontmatter/frontmatterBlock.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** 0-based line/column range in the document. */ -export type TextRange = { - startLine: number; - startCol: number; - endLine: number; - endCol: number; -}; - -export type FrontmatterBlock = { - /** YAML body between fences (no --- lines). */ - yaml: string; - /** Line index of opening `---`. */ - openLine: number; - /** Line index of closing `---`. */ - closeLine: number; - /** Inclusive range covering the whole frontmatter including fences. */ - range: TextRange; -}; - -/** - * Extract YAML frontmatter fenced by `---` lines at the start of the file. - * Returns null if there is no opening fence on line 0. - */ -export function extractFrontmatterBlock( - content: string, -): FrontmatterBlock | null { - const lines = content.split(/\r?\n/); - if (lines.length === 0 || lines[0].trim() !== "---") { - return null; - } - - let closeLine = -1; - for (let i = 1; i < lines.length; i += 1) { - if (lines[i].trim() === "---") { - closeLine = i; - break; - } - } - - if (closeLine < 0) { - return { - yaml: lines.slice(1).join("\n"), - openLine: 0, - closeLine: -1, - range: { - startLine: 0, - startCol: 0, - endLine: Math.max(0, lines.length - 1), - endCol: lines[lines.length - 1]?.length ?? 0, - }, - }; - } - - const yamlLines = lines.slice(1, closeLine); - return { - yaml: yamlLines.join("\n"), - openLine: 0, - closeLine, - range: { - startLine: 0, - startCol: 0, - endLine: closeLine, - endCol: lines[closeLine].length, - }, - }; -} - -/** Range of `key:` line value inside frontmatter, or full key line / whole FM. */ -export function findFieldRange( - content: string, - key: string, -): TextRange | undefined { - const block = extractFrontmatterBlock(content); - if (!block || block.closeLine < 0) { - return block?.range; - } - - const lines = content.split(/\r?\n/); - const keyPattern = new RegExp(`^(\\s*)(${escapeRegExp(key)})\\s*:\\s*(.*)$`); - - for (let i = block.openLine + 1; i < block.closeLine; i += 1) { - const line = lines[i] ?? ""; - const match = keyPattern.exec(line); - if (!match) continue; - - const indent = match[1] ?? ""; - const valueText = match[3] ?? ""; - const colonIndex = line.indexOf(":", indent.length); - const afterColon = colonIndex + 1; - let valueCol = afterColon; - while ( - valueCol < line.length && - (line[valueCol] === " " || line[valueCol] === "\t") - ) { - valueCol += 1; - } - - if (valueText.length === 0) { - return { - startLine: i, - startCol: 0, - endLine: i, - endCol: line.length, - }; - } - - return { - startLine: i, - startCol: valueCol, - endLine: i, - endCol: line.length, - }; - } - - return undefined; -} - -/** Full line range for a frontmatter key (key + value). */ -export function findFieldLineRange( - content: string, - key: string, -): TextRange | undefined { - const block = extractFrontmatterBlock(content); - if (!block || block.closeLine < 0) { - return undefined; - } - - const lines = content.split(/\r?\n/); - const keyPattern = new RegExp(`^\\s*${escapeRegExp(key)}\\s*:`); - - for (let i = block.openLine + 1; i < block.closeLine; i += 1) { - const line = lines[i] ?? ""; - if (keyPattern.test(line)) { - return { - startLine: i, - startCol: 0, - endLine: i, - endCol: line.length, - }; - } - } - - return undefined; -} - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} diff --git a/src/frontmatter/frontmatterFields.test.ts b/src/frontmatter/frontmatterFields.test.ts index ac8087b..5fd838f 100644 --- a/src/frontmatter/frontmatterFields.test.ts +++ b/src/frontmatter/frontmatterFields.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + extractFrontmatterBlock, findFieldRange, formatTagsYamlValue, replaceFrontmatterField, @@ -76,3 +77,55 @@ describe("setFrontmatterTags", () => { expect(result.content).not.toContain("tags: [old]"); }); }); + +describe("extractFrontmatterBlock", () => { + it("extracts yaml from a valid block", () => { + const block = extractFrontmatterBlock( + "---\ntitle: Hello\nstatus: todo\n---\nbody", + ); + expect(block).not.toBeNull(); + expect(block!.yaml).toBe("title: Hello\nstatus: todo"); + expect(block!.closeLine).toBe(3); + }); + + it("returns null when content does not start with ---", () => { + expect(extractFrontmatterBlock("no frontmatter")).toBeNull(); + expect(extractFrontmatterBlock("")).toBeNull(); + }); + + it("handles unclosed frontmatter with trailing newline", () => { + const block = extractFrontmatterBlock("---\ntitle: x\n"); + expect(block).not.toBeNull(); + expect(block!.closeLine).toBe(-1); + expect(block!.yaml).toBe("title: x\n"); + }); + + it("handles unclosed frontmatter without trailing newline", () => { + const block = extractFrontmatterBlock("---\ntitle: x"); + expect(block).not.toBeNull(); + expect(block!.closeLine).toBe(-1); + expect(block!.yaml).toBe("title: x"); + }); + + it("handles CRLF line endings (yaml is normalized to LF)", () => { + const block = extractFrontmatterBlock( + "---\r\ntitle: Hello\r\nstatus: todo\r\n---\r\nbody", + ); + expect(block).not.toBeNull(); + expect(block!.yaml).toBe("title: Hello\nstatus: todo"); + }); + + it("handles --- with trailing whitespace", () => { + const block = extractFrontmatterBlock( + "--- \ntitle: Hello\n--- \nbody", + ); + expect(block).not.toBeNull(); + expect(block!.yaml).toBe("title: Hello"); + expect(block!.closeLine).toBe(2); + }); + + it("only matches --- on the first line", () => { + const block = extractFrontmatterBlock("text\n---\ntitle: x\n---\n"); + expect(block).toBeNull(); + }); +}); diff --git a/src/frontmatter/frontmatterFields.ts b/src/frontmatter/frontmatterFields.ts index 150d2fd..858a44e 100644 --- a/src/frontmatter/frontmatterFields.ts +++ b/src/frontmatter/frontmatterFields.ts @@ -1,13 +1,138 @@ import { yamlInlineScalar } from "@/utils/yamlScalar"; -import { - extractFrontmatterBlock, - findFieldLineRange, - findFieldRange, - type TextRange, -} from "./frontmatterBlock"; -export { findFieldLineRange, findFieldRange }; -export type { TextRange }; +/** 0-based line/column range in the document. */ +export type TextRange = { + startLine: number; + startCol: number; + endLine: number; + endCol: number; +}; + +type FrontmatterBlock = { + yaml: string; + openLine: number; + closeLine: number; + range: TextRange; +}; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function extractFrontmatterBlock(content: string): FrontmatterBlock | null { + const NOT_FOUND = -1; + const lines = content.split(/\r?\n/); + if (lines.length === 0 || lines[0].trim() !== "---") { + return null; + } + + let closeLine = NOT_FOUND; + for (let i = 1; i < lines.length; i += 1) { + if (lines[i].trim() === "---") { + closeLine = i; + break; + } + } + + if (closeLine === NOT_FOUND) { + return { + yaml: lines.slice(1).join("\n"), + openLine: 0, + closeLine: NOT_FOUND, + range: { + startLine: 0, + startCol: 0, + endLine: Math.max(0, lines.length - 1), + endCol: lines[lines.length - 1]?.length ?? 0, + }, + }; + } + + const yamlLines = lines.slice(1, closeLine); + return { + yaml: yamlLines.join("\n"), + openLine: 0, + closeLine, + range: { + startLine: 0, + startCol: 0, + endLine: closeLine, + endCol: lines[closeLine].length, + }, + }; +} + +export function findFieldRange(content: string, key: string): TextRange | undefined { + const block = extractFrontmatterBlock(content); + if (!block || block.closeLine < 0) { + return block?.range; + } + + const lines = content.split(/\r?\n/); + const keyPattern = new RegExp(`^(\\s*)(${escapeRegExp(key)})\\s*:\\s*(.*)$`); + + for (let i = block.openLine + 1; i < block.closeLine; i += 1) { + const line = lines[i] ?? ""; + const match = keyPattern.exec(line); + if (!match) continue; + + const [, indent, , valueText = ""] = match; + const colonIndex = line.indexOf(":", indent.length); + const afterColon = colonIndex + 1; + let valueCol = afterColon; + while ( + valueCol < line.length && + (line[valueCol] === " " || line[valueCol] === "\t") + ) { + valueCol += 1; + } + + if (valueText.length === 0) { + return { + startLine: i, + startCol: 0, + endLine: i, + endCol: line.length, + }; + } + + return { + startLine: i, + startCol: valueCol, + endLine: i, + endCol: line.length, + }; + } + + return undefined; +} + +function findFieldLineRange( + content: string, + key: string, +): TextRange | undefined { + const block = extractFrontmatterBlock(content); + if (!block || block.closeLine < 0) { + return undefined; + } + + const lines = content.split(/\r?\n/); + const keyPattern = new RegExp(`^\\s*${escapeRegExp(key)}\\s*:`); + + for (let i = block.openLine + 1; i < block.closeLine; i += 1) { + const line = lines[i] ?? ""; + if (keyPattern.test(line)) { + return { + startLine: i, + startCol: 0, + endLine: i, + endCol: line.length, + }; + } + } + + return undefined; +} export type ReplaceFrontmatterFieldResult = | { ok: true; content: string; range: TextRange } diff --git a/src/frontmatter/parseTaskTags.ts b/src/frontmatter/parseTaskTags.ts index eadc3ce..e1a24f3 100644 --- a/src/frontmatter/parseTaskTags.ts +++ b/src/frontmatter/parseTaskTags.ts @@ -1,5 +1,5 @@ import * as yaml from "js-yaml"; -import { extractFrontmatterBlock } from "./frontmatterBlock"; +import { extractFrontmatterBlock } from "./frontmatterFields"; /** Read current tags array from task file content (empty if missing/invalid). */ export function parseTaskTags(content: string): string[] { diff --git a/src/frontmatter/validateFrontmatter.ts b/src/frontmatter/validateFrontmatter.ts index 4a3b4d0..8a16750 100644 --- a/src/frontmatter/validateFrontmatter.ts +++ b/src/frontmatter/validateFrontmatter.ts @@ -6,7 +6,7 @@ import { extractFrontmatterBlock, findFieldRange, type TextRange, -} from "./frontmatterBlock"; +} from "./frontmatterFields"; export type FrontmatterIssueSeverity = "error" | "warning";