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
258 changes: 226 additions & 32 deletions src/documentLinks/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import {
normalizeReferenceLabel,
} from "./shared.js";

const MAX_MARKDOWN_REFERENCE_LABEL_SCAN_LENGTH = 999;
const MAX_MARKDOWN_INLINE_LABEL_SCAN_LENGTH = Number.POSITIVE_INFINITY;

export function extractMarkdownModuleSpecifiers(source: string): ModuleSpecifier[] {
const sanitized = stripMarkdownCode(source);
return extractMarkdownModuleSpecifiersFromSanitized(sanitized);
Expand All @@ -25,21 +28,12 @@ function extractMarkdownModuleSpecifiersFromSanitized(sanitized: string): Module
if (normalized) out.push(normalized);
}

for (const match of sanitized.matchAll(/!?\[([^\]]+)\]\[([^\]]*)\]/g)) {
const fullMatch = match[0] ?? "";
if (fullMatch.startsWith("!")) continue;
const text = match[1]?.trim();
const label = match[2]?.trim();
const resolvedLabel = normalizeReferenceLabel(label || text);
if (!resolvedLabel) continue;
const destination = referenceDefs.get(resolvedLabel);
if (!destination) continue;
out.push(destination);
}
out.push(...collectMarkdownReferenceLinkSpecifiers(sanitized, referenceDefs));

for (const match of sanitized.matchAll(/<([^>\s]+)>/g)) {
const candidate = match[1]?.trim();
if (!candidate) continue;
if (match.index !== undefined && isMarkdownAngleDestinationInLinkSyntax(sanitized, match.index)) continue;
if (candidate.startsWith("/") || candidate.startsWith("?")) continue;
if (!isLikelyMarkdownAutolinkTarget(candidate)) continue;
const normalized = normalizeLinkSpecifier(candidate, {
Expand Down Expand Up @@ -67,32 +61,146 @@ export function extractMdxModuleSpecifiers(source: string): ModuleSpecifier[] {

function collectMarkdownReferenceDefinitions(source: string): Map<string, ModuleSpecifier> {
const out = new Map<string, ModuleSpecifier>();
const definitionRe = /^\s{0,3}\[([^\]]+)\]:\s*(<[^>\n]+>|[^ \t\n]+)(?:[ \t]+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*$/gm;

for (const match of source.matchAll(definitionRe)) {
const label = normalizeReferenceLabel(match[1]);
const rawDestination = match[2];
if (!label || !rawDestination) continue;
for (let lineStart = 0; lineStart < source.length; lineStart += 1) {
const lineEnd = source.indexOf("\n", lineStart);
const endIndex = lineEnd >= 0 ? lineEnd : source.length;
const line = source.slice(lineStart, endIndex);
const leading = line.match(/^ {0,3}/)?.[0] ?? "";
const labelStart = leading.length;
if (line[labelStart] !== "[") {
lineStart = endIndex;
continue;
}

const absoluteLabelStart = lineStart + labelStart;
const labelEnd = findMarkdownLabelEnd(source, absoluteLabelStart + 1, MAX_MARKDOWN_REFERENCE_LABEL_SCAN_LENGTH);
if (labelEnd < 0 || labelEnd > lineStart + line.length || source[labelEnd + 1] !== ":") {
lineStart = endIndex;
continue;
}

const label = normalizeReferenceLabel(source.slice(absoluteLabelStart + 1, labelEnd));
if (!label) {
lineStart = endIndex;
continue;
}

const rawDestination = parseMarkdownReferenceDefinitionDestination(source.slice(labelEnd + 2, endIndex));
if (!rawDestination) {
lineStart = endIndex;
continue;
}
const normalized = normalizeLinkSpecifier(rawDestination, {
preferRelative: true,
resolutionKind: "document",
});
if (normalized) out.set(label, normalized);
lineStart = endIndex;
}

return out;
}

function collectMarkdownReferenceLinkSpecifiers(
source: string,
referenceDefs: ReadonlyMap<string, ModuleSpecifier>,
): ModuleSpecifier[] {
const out: ModuleSpecifier[] = [];

for (let index = 0; index < source.length; index += 1) {
if (source[index] !== "[") continue;
if (source[index - 1] === "!") {
const labelEnd = findMarkdownLabelEnd(source, index + 1, MAX_MARKDOWN_INLINE_LABEL_SCAN_LENGTH);
if (labelEnd < 0) {
index = skipConsecutiveMarkdownOpeners(source, index);
continue;
}
const suffix = parseMarkdownReferenceSuffix(source, labelEnd + 1);
if (suffix) {
index = suffix.endIndex;
continue;
}
if (source[labelEnd + 1] === "(") {
const parsed = parseMarkdownInlineLink(source, labelEnd + 2);
index = parsed?.endIndex ?? labelEnd;
continue;
}
index = labelEnd;
continue;
}
const inlineLabelEnd = findMarkdownLabelEnd(source, index + 1, MAX_MARKDOWN_INLINE_LABEL_SCAN_LENGTH);
if (inlineLabelEnd >= 0 && source[inlineLabelEnd + 1] === "(") {
const parsed = parseMarkdownInlineLink(source, inlineLabelEnd + 2);
if (parsed) {
index = parsed.endIndex;
continue;
}
if (isEmptyMarkdownInlineDestination(source, inlineLabelEnd + 2)) {
index = findEmptyMarkdownInlineDestinationEnd(source, inlineLabelEnd + 2);
continue;
}
}

const labelEnd = findMarkdownLabelEnd(source, index + 1, MAX_MARKDOWN_REFERENCE_LABEL_SCAN_LENGTH);
if (labelEnd < 0) {
index = skipConsecutiveMarkdownOpeners(source, index);
continue;
}

const suffix = parseMarkdownReferenceSuffix(source, labelEnd + 1);
if (!suffix && isMarkdownReferenceDefinitionLabel(source, index, labelEnd)) {
const lineEnd = source.indexOf("\n", labelEnd + 1);
index = lineEnd >= 0 ? lineEnd : source.length;
continue;
}

const text = source.slice(index + 1, labelEnd).trim();
const rawLabel = suffix ? suffix.label.trim() || text : text;
const resolvedLabel = normalizeReferenceLabel(rawLabel);
if (!resolvedLabel) continue;

const destination = referenceDefs.get(resolvedLabel);
if (!destination) continue;
out.push(destination);
index = suffix?.endIndex ?? labelEnd;
}

return out;
}

function isMarkdownReferenceDefinitionLabel(source: string, labelStartIndex: number, labelEndIndex: number): boolean {
const lineStart = source.lastIndexOf("\n", labelStartIndex - 1) + 1;
const prefix = source.slice(lineStart, labelStartIndex);
if (!/^\s{0,3}$/.test(prefix)) return false;
const lineEnd = source.indexOf("\n", labelEndIndex + 1);
const suffixEnd = lineEnd >= 0 ? lineEnd : source.length;
return /^\s*:/.test(source.slice(labelEndIndex + 1, suffixEnd));
}

function parseMarkdownReferenceSuffix(source: string, startIndex: number): { label: string; endIndex: number } | null {
if (source[startIndex] !== "[") return null;
const labelEnd = findMarkdownLabelEnd(source, startIndex + 1, MAX_MARKDOWN_REFERENCE_LABEL_SCAN_LENGTH);
if (labelEnd < 0) return null;
return {
label: source.slice(startIndex + 1, labelEnd),
endIndex: labelEnd,
};
}

function collectMarkdownInlineLinkDestinations(source: string): string[] {
const out: string[] = [];

for (let index = 0; index < source.length; index += 1) {
if (source[index] !== "[") continue;
if (source[index - 1] === "!") continue;

const labelEnd = findMarkdownLabelEnd(source, index + 1);
if (labelEnd < 0 || source[labelEnd + 1] !== "(") continue;

const labelEnd = findMarkdownLabelEnd(source, index + 1, MAX_MARKDOWN_INLINE_LABEL_SCAN_LENGTH);
if (labelEnd < 0) {
index = skipConsecutiveMarkdownOpeners(source, index);
continue;
}
if (source[labelEnd + 1] !== "(") continue;
const parsed = parseMarkdownInlineLink(source, labelEnd + 2);
if (!parsed) continue;

Expand All @@ -103,20 +211,10 @@ function collectMarkdownInlineLinkDestinations(source: string): string[] {
return out;
}

function extractMarkdownDestination(rawDestination: string): string {
const trimmed = rawDestination.trim();
if (!trimmed) return trimmed;
if (trimmed.startsWith("<")) {
const endIndex = trimmed.indexOf(">");
if (endIndex > 0) return trimmed.slice(0, endIndex + 1);
}
const whitespaceIndex = trimmed.search(/\s/);
return whitespaceIndex >= 0 ? trimmed.slice(0, whitespaceIndex) : trimmed;
}

function findMarkdownLabelEnd(source: string, openIndex: number): number {
function findMarkdownLabelEnd(source: string, openIndex: number, maxLength: number): number {
let depth = 0;
for (let index = openIndex; index < source.length; index += 1) {
const maxIndex = Math.min(source.length, openIndex + maxLength + 1);
for (let index = openIndex; index < maxIndex; index += 1) {
const char = source.charAt(index);
if (char === "\\") {
index += 1;
Expand All @@ -133,6 +231,102 @@ function findMarkdownLabelEnd(source: string, openIndex: number): number {
return -1;
}

function skipConsecutiveMarkdownOpeners(source: string, startIndex: number): number {
let index = startIndex;
while (source[index + 1] === "[") {
index += 1;
}
return index;
}

function parseMarkdownReferenceDefinitionDestination(rawTail: string): string | null {
const trimmed = rawTail.trim();
if (!trimmed) return null;

let destination = "";
let remainder = "";
if (trimmed.startsWith("<")) {
const endIndex = trimmed.indexOf(">");
if (endIndex <= 0) return null;
destination = trimmed.slice(0, endIndex + 1);
remainder = trimmed.slice(endIndex + 1).trim();
} else {
const whitespaceIndex = trimmed.search(/\s/);
if (whitespaceIndex < 0) {
return trimmed;
}
destination = trimmed.slice(0, whitespaceIndex);
remainder = trimmed.slice(whitespaceIndex).trim();
}

if (!remainder) return destination;
return isValidMarkdownReferenceTitle(remainder) ? destination : null;
}

function isValidMarkdownReferenceTitle(remainder: string): boolean {
const opener = remainder.charAt(0);
if (opener === '"' || opener === "'") {
return closesAtEnd(remainder, opener);
}
if (!remainder.startsWith("(")) return false;
return remainder.indexOf(")") === remainder.length - 1;
}

function closesAtEnd(value: string, delimiter: string): boolean {
for (let index = 1; index < value.length; index += 1) {
const char = value.charAt(index);
if (char === "\\") {
index += 1;
continue;
}
if (char !== delimiter) continue;
return index === value.length - 1;
}
return false;
}

function isEmptyMarkdownInlineDestination(source: string, startIndex: number): boolean {
return findEmptyMarkdownInlineDestinationEnd(source, startIndex) >= 0;
}

function findEmptyMarkdownInlineDestinationEnd(source: string, startIndex: number): number {
for (let index = startIndex; index < source.length; index += 1) {
const char = source.charAt(index);
if (char === ")") return index;
if (char === "\n") return -1;
if (!/\s/.test(char)) return -1;
}
return -1;
}

function isMarkdownAngleDestinationInLinkSyntax(source: string, matchIndex: number): boolean {
let index = matchIndex - 1;
while (index >= 0 && /\s/.test(source.charAt(index))) {
if (source.charAt(index) === "\n") return false;
index -= 1;
}
if (index >= 1 && source.charAt(index) === "(" && source.charAt(index - 1) === "]") {
return true;
}

const lineStart = source.lastIndexOf("\n", matchIndex - 1) + 1;
const labelOpen = lineStart + (source.slice(lineStart).match(/^ {0,3}/)?.[0].length ?? 0);
if (source[labelOpen] !== "[") return false;
const labelEnd = findMarkdownLabelEnd(source, labelOpen + 1, MAX_MARKDOWN_REFERENCE_LABEL_SCAN_LENGTH);
return labelEnd >= 0 && labelEnd < matchIndex && /^\s*:\s*$/.test(source.slice(labelEnd + 1, matchIndex));
}

function extractMarkdownDestination(rawDestination: string): string {
const trimmed = rawDestination.trim();
if (!trimmed) return trimmed;
if (trimmed.startsWith("<")) {
const endIndex = trimmed.indexOf(">");
if (endIndex > 0) return trimmed.slice(0, endIndex + 1);
}
const whitespaceIndex = trimmed.search(/\s/);
return whitespaceIndex >= 0 ? trimmed.slice(0, whitespaceIndex) : trimmed;
}

function parseMarkdownInlineLink(source: string, startIndex: number): { destination: string; endIndex: number } | null {
let depth = 1;
let destinationEnd = -1;
Expand Down
15 changes: 13 additions & 2 deletions src/graph-edge-collector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
isGraphOnlyLanguage,
} from "./documentLinks.js";
import {
assertNativeRequiredAvailable,
getCompactImportsExecution,
type NativeRuntimeMode,
type CompactQueryResults,
Expand Down Expand Up @@ -82,6 +83,8 @@ export async function collectEdgesForFile(
const matchesGitSig = !!gitSig && !!cached?.gitSig && cached.gitSig === gitSig;
const matchesSig = !!sig && !!cached && cached.sig === sig;

assertNativeRequiredAvailable(opts.native);

if (cached && (matchesGitSig || matchesSig)) {
const cloned = cached.edges.map(cloneEdge);
emitCacheEntry(cloned);
Expand All @@ -94,13 +97,21 @@ export async function collectEdgesForFile(
let src = parsed?.source;
let nativeQueries = parsed?.nativeQueries ?? null;
let compactNativeImports: CompactQueryResults | null = null;
let graphOnlyLanguage = sup ? isGraphOnlyLanguage(sup.id) : false;
if (sup && graphOnlyLanguage) {
assertNativeRequiredAvailable(opts.native);
}
if (!sup || src === undefined) {
const prep = await prepareSourceInput(file);
sup = prep.sup;
src = prep.source;
graphOnlyLanguage = isGraphOnlyLanguage(sup.id);
if (graphOnlyLanguage) {
assertNativeRequiredAvailable(opts.native);
}
const fastRegexDisabled = opts.fastRegexDisabledLanguages?.includes(sup.id);
const shouldSkipNativeForFastGraph = !!opts.fast && (sup.id === "ts" || sup.id === "js") && !fastRegexDisabled;
if (!shouldSkipNativeForFastGraph) {
if (!graphOnlyLanguage && !shouldSkipNativeForFastGraph) {
// Use compact imports execution for graph mode -- smaller payload
const compactExecution = getCompactImportsExecution(src, sup, opts.native);
compactNativeImports = compactExecution.results;
Expand Down Expand Up @@ -146,7 +157,7 @@ export async function collectEdgesForFile(
}
}

const graphOnlyAliasLanguage = graphOnlyLanguageSupportsImportAliases(sup.id);
const graphOnlyAliasLanguage = graphOnlyLanguage && graphOnlyLanguageSupportsImportAliases(sup.id);
const needsGraphOnlyResolutionConfig =
graphOnlyAliasLanguage && specs.some(({ spec }) => graphOnlySpecifierNeedsResolutionConfig(spec));
const { matchPath } =
Expand Down
6 changes: 5 additions & 1 deletion src/graphs/symbol-graph-detailed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { isUnsupportedParserInputError, prepareSourceInput } from "../languages/
import type { SyntaxTreeLike } from "../languages/types.js";
import { logWithLevel, type LogLevel } from "../logging.js";
import { ProjectedSyntaxTree } from "../native/projectedTree.js";
import { getNativeSyntaxTreeExecution } from "../native/treeSitterNative.js";
import { assertNativeRequiredAvailable, getNativeSyntaxTreeExecution, isNativeRequiredUnavailableError } from "../native/treeSitterNative.js";
import { SymbolKind, type ProjectIndex, type ResolvedExport, type SymbolDef } from "../indexer/types.js";
import type { FileId } from "../types.js";
import { buildSymbolGraph, type SymbolGraph } from "./symbol-graph.js";
Expand Down Expand Up @@ -34,6 +34,7 @@ export async function buildSymbolGraphDetailed(
index: ProjectIndex,
opts?: BuildDetailedSymbolGraphOptions,
): Promise<SymbolGraph> {
assertNativeRequiredAvailable(index.nativeMode);
const base = await buildSymbolGraph(index, opts?.files ? { files: opts.files } : undefined);
const nodes = new Map(base.nodes);
const edges = base.edges.slice();
Expand Down Expand Up @@ -274,6 +275,9 @@ export async function buildSymbolGraphDetailed(
emitClassInheritanceEdges(edgePassContext, classNodes);
emitRustImplEdges(edgePassContext, tree.rootNode);
} catch (error) {
if (isNativeRequiredUnavailableError(error)) {
throw error;
}
if (isUnsupportedParserInputError(error)) {
continue;
}
Expand Down
Loading
Loading