diff --git a/modules/records/records.ts b/modules/records/records.ts index 0ce75d6..6b7735d 100644 --- a/modules/records/records.ts +++ b/modules/records/records.ts @@ -153,9 +153,15 @@ export class Records { * Where a phrase appears, case-insensitively, as written — no stemming, no ranking, because a * design record is found by its own words and a reader deciding which words matter would be a * second opinion about somebody else's document. Bounded, and says when it was. + * + * **The record is wrapped prose, and a phrase does not know where the line ends.** Every document + * here wraps at a hundred columns, so a phrase of six words is as likely to straddle a line break + * as not; matched line by line, the first live search for a sentence of ADR 0025 found nothing. + * So a line is matched together with the one after it, joined by a space, and emphasis marks + * are ignored — `**reachable**` is the word reachable. A hit still names the line it starts on. */ async search(query: string, limit = MOST_HITS): Promise<{ hits: Hit[]; more: boolean; commit: string }> { - const needle = query.trim().toLowerCase(); + const needle = plain(query).toLowerCase().replace(/\s+/g, " ").trim(); if (!needle) throw new Error("search for a phrase; an empty one matches every line of every document"); const cap = Math.max(1, Math.min(limit, MOST_HITS)); const hits: Hit[] = []; @@ -164,15 +170,22 @@ export class Records { const text = await fs.readFile(join(this.dir, path), "utf8"); let heading = ""; const lines = text.split("\n"); + const flat = lines.map((l) => plain(l).toLowerCase().replace(/\s+/g, " ").trim()); for (let i = 0; i < lines.length; i++) { const line = lines[i]!; if (/^#{1,6}\s/.test(line)) heading = line.replace(/^#+\s*/, "").trim(); - if (line.toLowerCase().includes(needle)) { + const here = flat[i]!; + const next = i + 1 < flat.length ? flat[i + 1]! : ""; + // On this line, or across the break into the next — but not a phrase that begins on the + // next line alone, which is that line's hit. + const onThis = here.includes(needle); + const acrossTheBreak = !onThis && next !== "" && `${here} ${next}`.includes(needle) && !next.includes(needle); + if (onThis || acrossTheBreak) { if (hits.length >= cap) { more = true; break; } - hits.push({ path, line: i + 1, heading, text: line.trim() }); + hits.push({ path, line: i + 1, heading, text: acrossTheBreak ? `${line.trim()} ${lines[i + 1]!.trim()}` : line.trim() }); } } if (more) break; @@ -249,6 +262,11 @@ export async function recordsFromEnv(env: NodeJS.ProcessEnv = process.env): Prom return new Records(dir, origin, repository); } +/** A line without its markdown emphasis, so a phrase matches the words and not the marks. */ +function plain(line: string): string { + return line.replace(/[*_`]/g, ""); +} + async function exists(path: string): Promise { try { await fs.stat(path); diff --git a/modules/records/test/records.test.ts b/modules/records/test/records.test.ts index 1421fc0..104ddef 100644 --- a/modules/records/test/records.test.ts +++ b/modules/records/test/records.test.ts @@ -21,7 +21,7 @@ function aRepository(): string { mkdirSync(join(dir, "03-DESIGN")); writeFileSync(join(dir, "README.md"), "# A repository\n\nWhat this is.\n"); writeFileSync(join(dir, "02-DECISIONS/0001-a-decision.md"), "# 1. A decision\n\n## Context\n\nThe context.\n\n## Decision\n\nWe decided the thing.\n"); - writeFileSync(join(dir, "03-DESIGN/07-knowledge.md"), "# Knowledge\n\n## The stores\n\nSilence and success must never look alike.\n"); + writeFileSync(join(dir, "03-DESIGN/07-knowledge.md"), "# Knowledge\n\n## The stores\n\nSilence and success must never look alike.\n\nA phrase that is wrapped at the\ncolumn where every document wraps, and **reachable is not the same as\nsurfacing** under emphasis.\n"); git("add", "-A"); git("commit", "--quiet", "-m", "first"); return dir; @@ -39,6 +39,15 @@ test("a phrase that appears in one design document comes back from where it is w assert.equal(found.hits[0]!.heading, "The stores"); assert.match(found.commit, /^[0-9a-f]{40}$/, "the answer names the commit it was read at"); + // Wrapped prose: a phrase across the line break is found, once, at the line it starts on; and + // emphasis marks are not part of the words. + const wrapped = await records.search("wrapped at the column where"); + assert.deepEqual(wrapped.hits.map((h) => [h.path, h.line]), [["03-DESIGN/07-knowledge.md", 7]]); + const emphasised = await records.search("reachable is not the same as surfacing"); + assert.deepEqual(emphasised.hits.map((h) => [h.path, h.line]), [["03-DESIGN/07-knowledge.md", 8]]); + const alsoOnOneLine = await records.search("under emphasis"); + assert.equal(alsoOnOneLine.hits.length, 1, "a phrase on one line is not also counted from the line before it"); + const doc = await records.read("02-DECISIONS/0001-a-decision.md"); assert.match(doc.content, /We decided the thing/); assert.equal(doc.truncated, false);