refactor(markdown): derive code ranges from mdast

This commit is contained in:
murashit 2026-07-21 20:20:13 +09:00
parent 6bdd9e5972
commit 488344a74d
5 changed files with 152 additions and 70 deletions

View file

@ -0,0 +1,19 @@
import { fromMarkdown } from "mdast-util-from-markdown";
import { visit } from "unist-util-visit";
export type MarkdownCodeRange = readonly [start: number, end: number];
export function markdownCodeRanges(markdown: string): MarkdownCodeRange[] {
const ranges: MarkdownCodeRange[] = [];
visit(fromMarkdown(markdown), (node) => {
if (node.type !== "code" && node.type !== "inlineCode") return;
const start = node.position?.start.offset;
const end = node.position?.end.offset;
if (start !== undefined && end !== undefined) ranges.push([start, end]);
});
return ranges;
}
export function markdownCodeRangeContainsOffset(ranges: readonly MarkdownCodeRange[], offset: number): boolean {
return ranges.some(([start, end]) => offset >= start && offset < end);
}

View file

@ -8,6 +8,7 @@ import {
type SkillReference,
type VaultFileReference,
} from "../../../../domain/chat/input";
import { type MarkdownCodeRange, markdownCodeRangeContainsOffset, markdownCodeRanges } from "../../../../domain/markdown/code-ranges";
import {
type ActiveNoteContextReference,
type ComposerContextReferences,
@ -43,10 +44,11 @@ interface PreparedComposerInputOptions {
referenceActiveNoteOnSend: boolean;
}
function parsedWikiLinks(text: string): ParsedWikiLink[] {
function parsedWikiLinks(text: string, codeRanges: readonly MarkdownCodeRange[]): ParsedWikiLink[] {
const links: ParsedWikiLink[] = [];
const seen = new Set<string>();
for (const match of text.matchAll(WIKILINK_PATTERN)) {
if (markdownCodeRangeContainsOffset(codeRanges, match.index)) continue;
const rawValue = match[1];
if (rawValue === undefined) continue;
const raw = rawValue.trim();
@ -67,14 +69,15 @@ export function preparedUserInputWithWikiLinkReferencesSkillsAndContext(
contextReferences: ComposerContextReferences,
options: PreparedComposerInputOptions,
): PreparedComposerInput {
const contextReplacement = textWithContextReferences(text, contextReferences);
const codeRanges = markdownReferenceCodeRanges(text);
const contextReplacement = textWithContextReferences(text, contextReferences, codeRanges);
const resolvedText = contextReplacement.text;
const fileReferences: VaultFileReference[] = [];
const wikilinkReferences: ObsidianReference[] = [];
const seenPaths = new Set<string>();
const activeNoteSnapshots = contextReferences.activeNoteSnapshots ?? [];
for (const link of parsedWikiLinks(resolvedText)) {
for (const link of parsedWikiLinks(resolvedText, codeRanges)) {
const fileReference = activeNoteFileReferenceForLink(link, activeNoteSnapshots) ?? resolveFileReference(link.target);
if (!fileReference || seenPaths.has(fileReference.path)) continue;
seenPaths.add(fileReference.path);
@ -94,7 +97,7 @@ export function preparedUserInputWithWikiLinkReferencesSkillsAndContext(
const skillByName = firstEnabledSkillByName(skills);
const resolvedSkills: SkillReference[] = [];
const seenSkillPaths = new Set<string>();
for (const reference of parsedSkillReferences(resolvedText)) {
for (const reference of parsedSkillReferences(resolvedText, codeRanges)) {
const skill = skillByName.get(reference.toLowerCase());
if (!skill || seenSkillPaths.has(skill.path)) continue;
seenSkillPaths.add(skill.path);
@ -120,19 +123,24 @@ function activeNoteFileReferenceForLink(link: ParsedWikiLink, snapshots: readonl
function textWithContextReferences(
text: string,
contextReferences: ComposerContextReferences,
codeRanges: readonly MarkdownCodeRange[],
): { text: string; selections: SelectionContextReference[] } {
return {
text,
selections: selectionsReferencedByText(text, contextReferences.selectionSnapshots ?? []),
selections: selectionsReferencedByText(text, contextReferences.selectionSnapshots ?? [], codeRanges),
};
}
function selectionsReferencedByText(text: string, snapshots: readonly SelectionContextReference[]): SelectionContextReference[] {
function selectionsReferencedByText(
text: string,
snapshots: readonly SelectionContextReference[],
codeRanges: readonly MarkdownCodeRange[],
): SelectionContextReference[] {
const selections: SelectionContextReference[] = [];
const seen = new Set<string>();
for (const snapshot of snapshots) {
const marker = selectionContextReferenceMarker(snapshot);
if (!text.includes(marker) || seen.has(marker)) continue;
if (!includesOutsideMarkdownCode(text, marker, codeRanges) || seen.has(marker)) continue;
seen.add(marker);
selections.push(snapshot);
}
@ -200,10 +208,12 @@ function excerptLines(references: readonly (ObsidianReference & { excerpt: strin
});
}
function parsedSkillReferences(text: string): string[] {
function parsedSkillReferences(text: string, codeRanges: readonly MarkdownCodeRange[]): string[] {
const references: string[] = [];
const seen = new Set<string>();
for (const match of text.matchAll(SKILL_REFERENCE_PATTERN)) {
const prefix = match[1] ?? "";
if (markdownCodeRangeContainsOffset(codeRanges, match.index + prefix.length)) continue;
const name = match[2];
if (name === undefined) continue;
const key = name.toLowerCase();
@ -214,6 +224,19 @@ function parsedSkillReferences(text: string): string[] {
return references;
}
function markdownReferenceCodeRanges(text: string): MarkdownCodeRange[] {
return text.includes("[[") || text.includes("$") ? markdownCodeRanges(text) : [];
}
function includesOutsideMarkdownCode(text: string, value: string, codeRanges: readonly MarkdownCodeRange[]): boolean {
let index = text.indexOf(value);
while (index >= 0) {
if (!markdownCodeRangeContainsOffset(codeRanges, index)) return true;
index = text.indexOf(value, index + value.length);
}
return false;
}
function parseWikiLink(raw: string): ParsedWikiLink | null {
const trimmed = raw.trim();
if (!trimmed) return null;

View file

@ -1,4 +1,5 @@
type TextRange = [number, number];
import { markdownCodeRangeContainsOffset, markdownCodeRanges } from "../../../../../domain/markdown/code-ranges";
interface UserMessageDisplayInputItem {
type: string;
name?: string;
@ -12,7 +13,7 @@ export function userMessageDisplayText(text: string, input: readonly UserMessage
const codeRanges = markdownCodeRanges(text);
return text.replace(pattern, (match: string, prefix: string, name: string, offset: number) => {
const dollarIndex = offset + prefix.length;
return isIndexInRanges(dollarIndex, codeRanges) ? match : `${prefix}${markdownCodeSpan(`$${name}`)}`;
return markdownCodeRangeContainsOffset(codeRanges, dollarIndex) ? match : `${prefix}${markdownCodeSpan(`$${name}`)}`;
});
}
@ -36,64 +37,6 @@ function markdownCodeSpan(text: string): string {
return `${delimiter} ${text} ${delimiter}`;
}
function markdownCodeRanges(text: string): TextRange[] {
return [...markdownFenceRanges(text), ...markdownInlineCodeRanges(text)].sort((a, b) => a[0] - b[0]);
}
function markdownFenceRanges(text: string): TextRange[] {
const ranges: TextRange[] = [];
let active: { marker: string; start: number } | null = null;
let offset = 0;
for (const line of text.matchAll(/[^\n]*(?:\n|$)/g)) {
const value = line[0];
if (value.length === 0) break;
const fence = /^(?: {0,3})(`{3,}|~{3,})/.exec(value);
if (fence) {
const marker = fence[1];
if (!marker) continue;
if (!active) {
active = { marker, start: offset };
} else if (marker.startsWith(active.marker.charAt(0)) && marker.length >= active.marker.length) {
ranges.push([active.start, offset + value.length]);
active = null;
}
}
offset += value.length;
}
if (active) ranges.push([active.start, text.length]);
return ranges;
}
function markdownInlineCodeRanges(text: string): TextRange[] {
const ranges: TextRange[] = [];
const fenceRanges = markdownFenceRanges(text);
let index = 0;
while (index < text.length) {
if (isIndexInRanges(index, fenceRanges) || text[index] !== "`") {
index += 1;
continue;
}
const match = /`+/.exec(text.slice(index));
if (!match) {
index += 1;
continue;
}
const delimiter = match[0];
const end = text.indexOf(delimiter, index + delimiter.length);
if (end < 0) {
index += delimiter.length;
continue;
}
ranges.push([index, end + delimiter.length]);
index = end + delimiter.length;
}
return ranges;
}
function isIndexInRanges(index: number, ranges: readonly TextRange[]): boolean {
return ranges.some(([start, end]) => index >= start && index < end);
}
function escapeRegExp(value: string): string {
return value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
}

View file

@ -278,7 +278,19 @@ describe("turn item conversion preserves app-server semantics", () => {
content: [
{
type: "text",
text: "Use `$obsidian-codex-panel-maintain`.\n\n```\n$obsidian-codex-panel-maintain\n```\n\nThen $obsidian-codex-panel-maintain.",
text: [
"Use `$obsidian-codex-panel-maintain`.",
"",
"```text",
"$obsidian-codex-panel-maintain",
"```not-a-closing-fence",
"$obsidian-codex-panel-maintain",
"```",
"",
" $obsidian-codex-panel-maintain",
"",
"Then $obsidian-codex-panel-maintain.",
].join("\n"),
text_elements: [],
},
{ type: "skill", name: "obsidian-codex-panel-maintain", path: "/skills/obsidian-codex-panel-maintain/SKILL.md" },
@ -286,7 +298,19 @@ describe("turn item conversion preserves app-server semantics", () => {
};
expect(threadStreamItemFromTurnItem(item)).toMatchObject({
text: "Use `$obsidian-codex-panel-maintain`.\n\n```\n$obsidian-codex-panel-maintain\n```\n\nThen `$obsidian-codex-panel-maintain`.",
text: [
"Use `$obsidian-codex-panel-maintain`.",
"",
"```text",
"$obsidian-codex-panel-maintain",
"```not-a-closing-fence",
"$obsidian-codex-panel-maintain",
"```",
"",
" $obsidian-codex-panel-maintain",
"",
"Then `$obsidian-codex-panel-maintain`.",
].join("\n"),
});
});

View file

@ -170,6 +170,79 @@ describe("wikilink context", () => {
]);
});
it("does not resolve wikilinks or skills written as Markdown code", () => {
const text = [
"Use [[Visible]] and $Visible.",
"",
"`[[Inline]] $Inline`",
"",
"```text",
"[[Fenced]] $Fenced",
"```",
"",
" [[Indented]] $Indented",
].join("\n");
const skills = ["Visible", "Inline", "Fenced", "Indented"].map(
(name) =>
({
name,
description: name,
path: `/skills/${name.toLowerCase()}/SKILL.md`,
scope: "repo",
enabled: true,
}) as never,
);
const input = userInputWithWikiLinkReferencesAndSkills(text, (target) => ({ name: target, path: `${target}.md` }), skills);
expect(input).toEqual([
{ type: "text", text },
{ type: "fileReference", name: "Visible", path: "Visible.md" },
{ type: "skill", name: "Visible", path: "/skills/visible/SKILL.md" },
wikilinkContext("- [[Visible]] -> Visible.md"),
]);
});
it("does not attach selection snapshots referenced only inside Markdown code", () => {
const text = "```text\n[[Alpha]] (L42:C5-L47:C1)\n```";
const prepared = preparedUserInputWithWikiLinkReferencesSkillsAndContext(
text,
() => null,
[],
{
activeNote: null,
selection: null,
selectionSnapshots: [
{
name: "Alpha",
path: "notes/Alpha.md",
linktext: "Alpha",
range: { from: { line: 41, ch: 4 }, to: { line: 46, ch: 0 } },
text: "selected text",
},
],
},
{ referenceActiveNoteOnSend: false },
);
expect(prepared.input).toEqual([{ type: "text", text }]);
});
it("keeps raw Obsidian wikilinks intact when CommonMark would split their contents", () => {
const text = "Read [[A *strange* note]].";
const input = userInputWithWikiLinkReferencesAndSkills(
text,
(target) => (target === "A *strange* note" ? { name: target, path: "notes/Strange.md" } : null),
[],
);
expect(input).toEqual([
{ type: "text", text },
{ type: "fileReference", name: "A *strange* note", path: "notes/Strange.md" },
wikilinkContext("- [[A *strange* note]] -> notes/Strange.md"),
]);
});
it("leaves bare context references as raw text", () => {
const text = "整理して @active and @selection";
const prepared = preparedUserInputWithWikiLinkReferencesSkillsAndContext(