mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
refactor(markdown): derive code ranges from mdast
This commit is contained in:
parent
6bdd9e5972
commit
488344a74d
5 changed files with 152 additions and 70 deletions
19
src/domain/markdown/code-ranges.ts
Normal file
19
src/domain/markdown/code-ranges.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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, "\\$&");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue