Skip to content
735 changes: 616 additions & 119 deletions lib/modules/manager/ant/extract.spec.ts

Large diffs are not rendered by default.

182 changes: 160 additions & 22 deletions lib/modules/manager/ant/extract.ts
Comment thread
RahulGautamSingh marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import upath from 'upath';
import type { XmlElement } from 'xmldoc';
import { XmlDocument } from 'xmldoc';
import { logger } from '../../../logger/index.ts';
Expand All @@ -10,6 +11,15 @@ import type {
PackageFile,
PackageFileContent,
} from '../types.ts';
import {
applyProps,
findAttrValuePosition,
parsePropertiesFile,
resolveChainedProps,
} from './properties.ts';
import type { AntProp } from './types.ts';

export { parsePropertiesFile } from './properties.ts';

const scopeNames = new Set([
'compile',
Expand All @@ -26,38 +36,53 @@ function getDependencyType(scope: string | undefined): string {
return 'compile';
}

function collectDependency(node: XmlElement): PackageDependency | null {
interface RawDep {
dep: PackageDependency;
depPackageFile: string;
}

function collectDependency(
node: XmlElement,
packageFile: string,
content: string,
): RawDep | null {
const { groupId, artifactId, version, scope } = node.attr;

if (!version || !groupId || !artifactId) {
return null;
}

return {
const dep: PackageDependency = {
datasource: MavenDatasource.id,
depName: `${groupId}:${artifactId}`,
currentValue: version,
depType: getDependencyType(scope),
registryUrls: [],
};

dep.fileReplacePosition = findAttrValuePosition(content, node, 'version');

return { dep, depPackageFile: packageFile };
}

function walkNode(
node: XmlElement | XmlDocument,
deps: PackageDependency[],
rawDeps: RawDep[],
packageFile: string,
content: string,
): void {
for (const child of node.children) {
if (!isXmlElement(child)) {
continue;
}

if (child.name === 'dependency') {
const dep = collectDependency(child);
if (dep) {
deps.push(dep);
const rawDep = collectDependency(child, packageFile, content);
if (rawDep) {
rawDeps.push(rawDep);
}
} else {
walkNode(child, deps);
walkNode(child, rawDeps, packageFile, content);
}
}
}
Expand All @@ -74,8 +99,10 @@ export function extractPackageFile(
return null;
}

const deps: PackageDependency[] = [];
walkNode(doc, deps);
const rawDeps: RawDep[] = [];
walkNode(doc, rawDeps, packageFile, content);

const deps = rawDeps.map((rd) => rd.dep);

if (deps.length === 0) {
return null;
Expand All @@ -84,40 +111,151 @@ export function extractPackageFile(
return { deps };
}

async function walkXmlFile(
/**
* Walk an XML node tree in document order, processing properties,
* property file references, and dependencies as they appear.
*/
async function walkNodeInOrder(
node: XmlElement | XmlDocument,
packageFile: string,
content: string,
visitedFiles: Set<string>,
): Promise<PackageFile | null> {
if (visitedFiles.has(packageFile)) {
return null;
allProps: Record<string, AntProp>,
allRawDeps: RawDep[],
): Promise<void> {
const baseDir = upath.dirname(packageFile);

for (const child of node.children) {
if (!isXmlElement(child)) {
continue;
}

if (child.name === 'property') {
// Handle inline property definition
const name = child.attr.name;
const value = child.attr.value;
if (name && value && !(name in allProps)) {
const pos = findAttrValuePosition(content, child, 'value');
allProps[name] = { val: value, fileReplacePosition: pos, packageFile };
}

// Handle property file reference
const file = child.attr.file;
if (file) {
const propFilePath = file.startsWith('/')
? file
: upath.join(baseDir, file);

if (!visitedFiles.has(propFilePath)) {
visitedFiles.add(propFilePath);
const propContent = await readLocalFile(propFilePath, 'utf8');
if (propContent) {
parsePropertiesFile(propContent, propFilePath, allProps);
} else {
logger.debug(
`ant manager: could not read properties file ${propFilePath}`,
);
}
}
}
} else if (child.name === 'dependency') {
const rawDep = collectDependency(child, packageFile, content);
if (rawDep) {
allRawDeps.push(rawDep);
}
} else {
await walkNodeInOrder(
child,
packageFile,
content,
visitedFiles,
allProps,
allRawDeps,
);
}
}
}

async function walkXmlFile(
packageFile: string,
visitedFiles: Set<string>,
allProps: Record<string, AntProp>,
allRawDeps: RawDep[],
): Promise<void> {
visitedFiles.add(packageFile);

const content = await readLocalFile(packageFile, 'utf8');
if (!content) {
logger.debug(`ant manager: could not read ${packageFile}`);
return null;
return;
}

const result = extractPackageFile(content, packageFile);
if (!result) {
return null;
let doc: XmlDocument;
try {
doc = new XmlDocument(content);
} catch {
logger.debug(`ant manager: could not parse XML ${packageFile}`);
return;
}

return { packageFile, ...result };
await walkNodeInOrder(
doc,
packageFile,
content,
visitedFiles,
allProps,
allRawDeps,
);
}

export async function extractAllPackageFiles(
_config: ExtractConfig,
packageFiles: string[],
): Promise<PackageFile[] | null> {
const results: PackageFile[] = [];
const visitedFiles = new Set<string>();
const seen = new Set<string>();

for (const packageFile of packageFiles) {
const result = await walkXmlFile(packageFile, visitedFiles);
if (result) {
results.push(result);
if (seen.has(packageFile)) {
continue;
}
seen.add(packageFile);

const visitedFiles = new Set<string>();
const allProps: Record<string, AntProp> = {};
const allRawDeps: RawDep[] = [];

await walkXmlFile(packageFile, visitedFiles, allProps, allRawDeps);
Comment thread
RahulGautamSingh marked this conversation as resolved.

// Resolve chained property values before applying to deps
resolveChainedProps(allProps);

// Apply property resolution to all dependencies
const resolvedDeps = allRawDeps.map((rawDep) =>
applyProps(rawDep.dep, rawDep.depPackageFile, allProps),
);

if (resolvedDeps.length === 0) {
continue;
}

// Group deps by their target file (propSource or original packageFile)
const fileMap = new Map<string, PackageDependency[]>();
for (let i = 0; i < resolvedDeps.length; i++) {
const dep = resolvedDeps[i];
const targetFile = dep.propSource ?? allRawDeps[i].depPackageFile;
if (!fileMap.has(targetFile)) {
fileMap.set(targetFile, []);
}
fileMap.get(targetFile)!.push(dep);
}

for (const [pkgFile, deps] of fileMap) {
// Clean up internal propSource field
for (const dep of deps) {
delete dep.propSource;
}
results.push({ packageFile: pkgFile, deps });
}
}

Expand Down
1 change: 1 addition & 0 deletions lib/modules/manager/ant/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Category } from '../../../constants/index.ts';
import { MavenDatasource } from '../../datasource/maven/index.ts';

export { extractAllPackageFiles, extractPackageFile } from './extract.ts';
export { updateDependency } from './update.ts';

export const displayName = 'Apache Ant';
export const url = 'https://ant.apache.org';
Expand Down
78 changes: 78 additions & 0 deletions lib/modules/manager/ant/properties.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { parsePropertiesFile } from './properties.ts';
import type { AntProp } from './types.ts';

describe('modules/manager/ant/properties', () => {
describe('parsePropertiesFile', () => {
it('parses key=value pairs', () => {
const props: Record<string, AntProp> = {};
parsePropertiesFile(
'key1=value1\nkey2=value2\n',
'test.properties',
props,
);

expect(props.key1).toEqual(
expect.objectContaining({
val: 'value1',
packageFile: 'test.properties',
}),
);
expect(props.key2).toEqual(
expect.objectContaining({
val: 'value2',
packageFile: 'test.properties',
}),
);
});

it('skips comments and blank lines', () => {
const props: Record<string, AntProp> = {};
parsePropertiesFile(
'# comment\n\nkey=value\n! another comment\n',
'test.properties',
props,
);

expect(Object.keys(props)).toEqual(['key']);
});

it('supports colon separator', () => {
const props: Record<string, AntProp> = {};
parsePropertiesFile('key:value\n', 'test.properties', props);

expect(props.key).toEqual(expect.objectContaining({ val: 'value' }));
});

it('skips malformed lines without separators', () => {
const props: Record<string, AntProp> = {};
parsePropertiesFile(
'key=value\nmalformed_line_no_separator\nother=val\n',
'test.properties',
props,
);

expect(Object.keys(props)).toEqual(['key', 'other']);
});

it('implements first-definition-wins', () => {
const props: Record<string, AntProp> = {};
parsePropertiesFile('key=first\nkey=second\n', 'test.properties', props);

expect(props.key.val).toBe('first');
});

it('respects pre-existing props (first-definition-wins across sources)', () => {
const props: Record<string, AntProp> = {
key: {
val: 'existing',
fileReplacePosition: 0,
packageFile: 'build.xml',
},
};
parsePropertiesFile('key=new\n', 'test.properties', props);

expect(props.key.val).toBe('existing');
expect(props.key.packageFile).toBe('build.xml');
});
});
});
Loading
Loading