Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getIdentifierText, getStringSpecifier } from '../analyze/nodes';
export function getImportRelationForJavaScriptCallExpression(
callExpression: Parser.SyntaxNode,
filePath: string,
workspaceRoot?: string,
): IAnalysisRelation | null {
const calleeNode = callExpression.childForFieldName('function') ?? callExpression.namedChildren[0];
const argumentsNode = callExpression.childForFieldName('arguments')
Expand All @@ -19,7 +20,9 @@ export function getImportRelationForJavaScriptCallExpression(
return null;
}

const resolvedPath = resolveTreeSitterImportPath(filePath, specifier);
const resolvedPath = workspaceRoot
? resolveTreeSitterImportPath(filePath, specifier, workspaceRoot)
: resolveTreeSitterImportPath(filePath, specifier);
if (calleeNode?.type === 'import') {
return {
kind: 'import',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ export function handleJavaScriptCallExpression(
relations: IAnalysisRelation[],
importedBindings: ReadonlyMap<string, ImportedBinding>,
currentSymbolId?: string,
workspaceRoot?: string,
): void {
const importRelation = getImportRelationForJavaScriptCallExpression(node, filePath);
const importRelation = getImportRelationForJavaScriptCallExpression(node, filePath, workspaceRoot);
if (importRelation) {
relations.push(importRelation);
return;
Expand Down
16 changes: 14 additions & 2 deletions packages/core/src/treeSitter/runtime/analyzeJavaScript/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ type JavaScriptVisitContext = {
state: SymbolWalkState;
symbols: IAnalysisSymbol[];
symbolsEnabled: boolean;
workspaceRoot: string;
walk: (node: Parser.SyntaxNode, context: SymbolWalkState) => void;
};

Expand Down Expand Up @@ -58,6 +59,7 @@ const JAVASCRIPT_NODE_VISITORS: Record<string, JavaScriptNodeVisitor> = {
context.relations,
context.importedBindings,
context.state.currentSymbolId,
context.workspaceRoot,
);
},
class_declaration: (node, context) => {
Expand All @@ -78,14 +80,21 @@ const JAVASCRIPT_NODE_VISITORS: Record<string, JavaScriptNodeVisitor> = {
context.relations,
context.symbols,
context.symbolsEnabled,
context.workspaceRoot,
);
},
function_declaration: (node, context) =>
context.symbolsEnabled
? handleJavaScriptFunctionDeclaration(node, context.filePath, context.symbols, context.walk)
: undefined,
import_statement: (node, context) =>
handleJavaScriptImportStatement(node, context.filePath, context.relations, context.importedBindings),
handleJavaScriptImportStatement(
node,
context.filePath,
context.relations,
context.importedBindings,
context.workspaceRoot,
),
interface_declaration: handleTypeDeclarationNode,
method_definition: (node, context) =>
context.symbolsEnabled
Expand All @@ -107,6 +116,7 @@ function visitJavaScriptNode(
symbols: IAnalysisSymbol[],
importedBindings: Map<string, ImportedBinding>,
symbolsEnabled: boolean,
workspaceRoot: string,
): TreeWalkAction<SymbolWalkState> | void {
const visitor = JAVASCRIPT_NODE_VISITORS[node.type];
return visitor?.(node, {
Expand All @@ -116,14 +126,15 @@ function visitJavaScriptNode(
state,
symbols,
symbolsEnabled,
workspaceRoot,
walk,
});
}

export function analyzeJavaScriptFamilyFile(
filePath: string,
tree: Parser.Tree,
_workspaceRoot: string,
workspaceRoot: string,
options: TreeSitterAnalysisOptions = {},
): IFileAnalysisResult {
const importedBindings = new Map<string, ImportedBinding>();
Expand All @@ -140,6 +151,7 @@ export function analyzeJavaScriptFamilyFile(
symbols,
importedBindings,
symbolsEnabled,
workspaceRoot,
),
);
return normalizeAnalysisResult(filePath, symbols, relations);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export function handleJavaScriptImportStatement(
filePath: string,
relations: IAnalysisRelation[],
importedBindings: Map<string, ImportedBinding>,
workspaceRoot?: string,
): TreeWalkAction<SymbolWalkState> {
const specifier = getStringSpecifier(node.namedChildren.find((child) => child.type === 'string'));
if (!specifier) {
Expand All @@ -34,7 +35,9 @@ export function handleJavaScriptImportStatement(
importedBindings,
node,
relations,
resolvedPath: resolveTreeSitterImportPath(filePath, specifier),
resolvedPath: workspaceRoot
? resolveTreeSitterImportPath(filePath, specifier, workspaceRoot)
: resolveTreeSitterImportPath(filePath, specifier),
specifier,
};

Expand Down Expand Up @@ -82,10 +85,13 @@ export function handleJavaScriptExportStatement(
relations: IAnalysisRelation[],
symbols: IAnalysisSymbol[] = [],
symbolsEnabled = true,
workspaceRoot?: string,
): void {
const specifier = getStringSpecifier(node.namedChildren.find((child) => child.type === 'string'));
if (!specifier) return;
const resolvedPath = resolveTreeSitterImportPath(filePath, specifier);
const resolvedPath = workspaceRoot
? resolveTreeSitterImportPath(filePath, specifier, workspaceRoot)
: resolveTreeSitterImportPath(filePath, specifier);
const exportClause = node.namedChildren.find((child) => child.type === 'export_clause');
if (!exportClause) {
addReexportRelation(relations, filePath, specifier, resolvedPath, { reexportAll: true });
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/treeSitter/runtime/resolve.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as path from 'node:path';
import { findExistingFile } from './analyze/existingFile';
import { resolvePathMappingBases } from './resolve/pathMappings';

const IMPORT_RESOLUTION_EXTENSIONS = [
'',
Expand All @@ -23,8 +24,14 @@ function findExistingPath(basePath: string): string | null {
export function resolveTreeSitterImportPath(
filePath: string,
specifier: string,
workspaceRoot?: string,
): string | null {
if (!specifier.startsWith('.') && !specifier.startsWith('/')) {
if (!specifier.startsWith('.') && !path.isAbsolute(specifier)) {
if (!workspaceRoot) return null;
for (const candidateBasePath of resolvePathMappingBases(filePath, workspaceRoot, specifier)) {
const resolvedPath = findExistingPath(candidateBasePath);
if (resolvedPath) return resolvedPath;
}
return null;
}

Expand Down
168 changes: 168 additions & 0 deletions packages/core/src/treeSitter/runtime/resolve/pathMappings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import * as path from 'node:path';
import {
treeSitterPathIsFile,
treeSitterReadTextFile,
} from '../pathHost';

type PathMapping = {
pattern: string;
targets: readonly string[];
};

type PathMappingConfig = {
basePath: string;
mappings: readonly PathMapping[];
};

function parseJsonWithComments(source: string): unknown {
let output = '';
let inString = false;
let escaped = false;
let lineComment = false;
let blockComment = false;

for (let index = 0; index < source.length; index += 1) {
const character = source[index];
const nextCharacter = source[index + 1];

if (lineComment) {
if (character === '\n' || character === '\r') {
lineComment = false;
output += character;
} else {
output += ' ';
}
continue;
}

if (blockComment) {
if (character === '*' && nextCharacter === '/') {
output += ' ';
blockComment = false;
index += 1;
} else {
output += character === '\n' || character === '\r' ? character : ' ';
}
continue;
}

if (inString) {
output += character;
if (escaped) {
escaped = false;
} else if (character === '\\') {
escaped = true;
} else if (character === '"') {
inString = false;
}
continue;
}

if (character === '"') {
inString = true;
output += character;
} else if (character === '/' && nextCharacter === '/') {
lineComment = true;
output += ' ';
index += 1;
} else if (character === '/' && nextCharacter === '*') {
blockComment = true;
output += ' ';
index += 1;
} else {
output += character;
}
}

return JSON.parse(output.replace(/,\s*([}\]])/g, '$1')) as unknown;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function readPathMappingConfig(configPath: string): PathMappingConfig | null {
const source = treeSitterReadTextFile(configPath);
if (!source) return null;

try {
const config = parseJsonWithComments(source);
if (!isRecord(config) || !isRecord(config.compilerOptions)) return null;

const compilerOptions = config.compilerOptions;
if (!isRecord(compilerOptions.paths)) return null;

const configDirectory = path.dirname(configPath);
const basePath = typeof compilerOptions.baseUrl === 'string'
? path.resolve(configDirectory, compilerOptions.baseUrl)
: configDirectory;
const mappings = Object.entries(compilerOptions.paths).flatMap(([pattern, targets]) => {
if (!Array.isArray(targets)) return [];
const stringTargets = targets.filter((target): target is string => typeof target === 'string');
return stringTargets.length > 0 ? [{ pattern, targets: stringTargets }] : [];
});

return mappings.length > 0 ? { basePath, mappings } : null;
} catch {
return null;
}
}

function isWithinWorkspace(candidatePath: string, workspaceRoot: string): boolean {
const relativePath = path.relative(workspaceRoot, candidatePath);
return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
}

function findNearestPathMappingConfig(
filePath: string,
workspaceRoot: string,
): PathMappingConfig | null {
let directory = path.dirname(filePath);
const root = path.resolve(workspaceRoot);

if (!isWithinWorkspace(directory, root)) return null;

while (true) {
for (const configName of ['tsconfig.json', 'jsconfig.json']) {
const configPath = path.join(directory, configName);
if (treeSitterPathIsFile(configPath)) {
const config = readPathMappingConfig(configPath);
if (config) return config;
}
}

if (directory === root) return null;
const parentDirectory = path.dirname(directory);
if (parentDirectory === directory || !isWithinWorkspace(parentDirectory, root)) return null;
directory = parentDirectory;
}
}

function matchPathPattern(pattern: string, specifier: string): string | null {
const wildcardIndex = pattern.indexOf('*');
if (wildcardIndex < 0) return pattern === specifier ? '' : null;

const prefix = pattern.slice(0, wildcardIndex);
const suffix = pattern.slice(wildcardIndex + 1);
if (!specifier.startsWith(prefix) || !specifier.endsWith(suffix)) return null;
return specifier.slice(prefix.length, specifier.length - suffix.length);
}

export function resolvePathMappingBases(
filePath: string,
workspaceRoot: string,
specifier: string,
): string[] {
const config = findNearestPathMappingConfig(filePath, workspaceRoot);
if (!config) return [];

for (const mapping of config.mappings) {
const wildcard = matchPathPattern(mapping.pattern, specifier);
if (wildcard === null) continue;
return mapping.targets.map(target =>
path.resolve(config.basePath, target.replace('*', wildcard)),
);
}

return [];
}
35 changes: 35 additions & 0 deletions packages/core/tests/treeSitter/resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,41 @@ describe('pipeline/plugins/treesitter/runtime/resolve', () => {
expect(resolveTreeSitterImportPath(filePath, './missing')).toBeNull();
});

it('resolves compiler path mappings from the nearest tsconfig', () => {
const workspaceRoot = createWorkspaceRoot();
const filePath = writeWorkspaceFile(workspaceRoot, 'src/components/MeasureTool.vue', '');
const storePath = writeWorkspaceFile(workspaceRoot, 'src/stores/useMeasureStore.ts', '');
writeWorkspaceFile(workspaceRoot, 'tsconfig.json', `{
// Vite source alias
"compilerOptions": {
"paths": {
"@/*": ["./src/*"],
},
},
}`);

expect(resolveTreeSitterImportPath(
filePath,
'@/stores/useMeasureStore',
workspaceRoot,
)).toBe(storePath);
});

it('uses baseUrl and ordered path mapping targets without resolving external packages', () => {
const workspaceRoot = createWorkspaceRoot();
const filePath = writeWorkspaceFile(workspaceRoot, 'packages/app/src/app.ts', '');
const fallbackPath = writeWorkspaceFile(workspaceRoot, 'packages/app/source/lib/helper.ts', '');
writeWorkspaceFile(workspaceRoot, 'packages/app/tsconfig.json', JSON.stringify({
compilerOptions: {
baseUrl: '.',
paths: { '@app/*': ['missing/*', 'source/*'] },
},
}));

expect(resolveTreeSitterImportPath(filePath, '@app/lib/helper', workspaceRoot)).toBe(fallbackPath);
expect(resolveTreeSitterImportPath(filePath, 'react', workspaceRoot)).toBeNull();
});

it('resolves exact files before trying extension variants and index fallbacks', () => {
const workspaceRoot = createWorkspaceRoot();
const filePath = writeWorkspaceFile(workspaceRoot, 'src/app.ts', 'export {};\n');
Expand Down
Loading