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
81 changes: 56 additions & 25 deletions packageManager/src/version/Range.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,67 +8,98 @@ import { Version } from './Version';
export class Range {
private range: semver.Range;
private rangeSpec: string;
// null = neutral (wildcard/latest — matches both R and LTS)
private isRRange: boolean | null = null;

/**
* Normalize R-release notation in a range spec to plain semver.
* e.g. '^20R2.3' → '^20.2.3', '20R2.*' → '20.2.x', '20R2' → '20.2.0'
*/
private static normalizeRSpec(spec: string): string {
return spec
.replace(/(\d+)\.R(\d+)\.(\d+)/g, '$1.$2.$3') // 20.R2.3 → 20.2.3
.replace(/(\d+)\.R(\d+)/g, '$1.$2.0') // 20.R2 → 20.2.0
.replace(/(\d+)R(\d+)\.(\d+)/g, '$1.$2.$3') // 20R2.3 → 20.2.3
.replace(/(\d+)R(\d+)\.\*/g, '$1.$2.x') // 20R2.* → 20.2.x
.replace(/(\d+)R(\d+)/g, '$1.$2.0'); // 20R2 → 20.2.0
}

constructor(rangeSpec: string, ideVersion?: Version) {
this.rangeSpec = rangeSpec;

// Handle special keywords
if (rangeSpec === 'latest' || rangeSpec === '*' || !rangeSpec) {
this.range = new semver.Range('*');
this.isRRange = null; // neutral
} else if (rangeSpec.toLowerCase() === '4d') {
// Match IDE version
// Match IDE version — type follows the IDE's type
const version = ideVersion?.toIDEString();
this.range = new semver.Range(`^${version}`);
this.isRRange = ideVersion?.isR ?? null;
} else {
// Parse as semver range
this.range = new semver.Range(rangeSpec);
// Detect R-release notation before normalizing
this.isRRange = /\d+\.?R\d+/.test(rangeSpec);
// Parse as semver range (normalize R notation first if needed)
const normalizedSpec = this.isRRange ? Range.normalizeRSpec(rangeSpec) : rangeSpec;
this.range = new semver.Range(normalizedSpec);
}
}

/**
* Check if a version satisfies this range
* Check if a version satisfies this range.
* Mirrors 4D semantics: R-release and LTS are separate tracks within the same
* major; a typed range (R or LTS) rejects versions from the other track.
* Neutral ranges (*, latest, empty) match both.
*/
satisfiedBy(version: string | Version): boolean {
const versionString = version instanceof Version
? version.toString()
: version.replace(/^v/, '');
const v = version instanceof Version ? version : Version.parse(version);
if (!v) return false;

return semver.satisfies(versionString, this.range);
// Type-compatibility guard
if (this.isRRange !== null && v.isR !== this.isRRange) {
return false;
}

return semver.satisfies(v.toString(), this.range);
}

/**
* Find the maximum version that satisfies this range
*/
maxSatisfying(versions: string[]): string | null {
const cleanVersions = versions.map(v => v.replace(/^v/, ''));
const result = semver.maxSatisfying(cleanVersions, this.range);
const filtered = this.isRRange !== null
? versions.filter(v => {
const parsed = Version.parse(v);
return parsed ? parsed.isR === this.isRRange : true;
})
: versions;
// Normalize each version string to semver, keeping a map back to original
const normalized = filtered.map(v => (Version.parse(v)?.toString() ?? v.replace(/^v/, '')));
const result = semver.maxSatisfying(normalized, this.range);
if (!result) {
return null;
}

// Return with original 'v' prefix if it existed
const originalVersion = versions.find(v =>
v.replace(/^v/, '') === result
);
return originalVersion || result;
const idx = normalized.indexOf(result);
return idx >= 0 ? filtered[idx] : result;
}

/**
* Find the minimum version that satisfies this range
*/
minSatisfying(versions: string[]): string | null {
const cleanVersions = versions.map(v => v.replace(/^v/, ''));
const result = semver.minSatisfying(cleanVersions, this.range);

const filtered = this.isRRange !== null
? versions.filter(v => {
const parsed = Version.parse(v);
return parsed ? parsed.isR === this.isRRange : true;
})
: versions;
const normalized = filtered.map(v => (Version.parse(v)?.toString() ?? v.replace(/^v/, '')));
const result = semver.minSatisfying(normalized, this.range);
if (!result) {
return null;
}

// Return with original 'v' prefix if it existed
const originalVersion = versions.find(v =>
v.replace(/^v/, '') === result
);
return originalVersion || result;
const idx = normalized.indexOf(result);
return idx >= 0 ? filtered[idx] : result;
}

/**
Expand Down
43 changes: 36 additions & 7 deletions packageManager/src/version/Version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ export class Version {
// 20R2 → 20.2.0
clean = clean.replace(/R/, '.') + '.0';
this.isR = true;
} else if (/^\d{2}R\d+\.\d+$/.test(clean)) {
// 20R2.1 → 20.2.1
clean = clean.replace(/R/, '.');
this.isR = true;
} else if (/^\d{2}\.R\d+(\.\d+)?$/.test(clean)) {
// 20.R2.3 → 20.2.3 or 20.R2 → 20.2.0
clean = clean.replace(/\.R/, '.');
Expand Down Expand Up @@ -85,43 +89,68 @@ export class Version {
* Check if this version is greater than another
*/
gt(other: Version): boolean {
return semver.gt(this.semver, other.semver);
return this.compare(other) > 0;
}

/**
* Check if this version is greater than or equal to another
*/
gte(other: Version): boolean {
return semver.gte(this.semver, other.semver);
return this.compare(other) >= 0;
}

/**
* Check if this version is less than another
*/
lt(other: Version): boolean {
return semver.lt(this.semver, other.semver);
return this.compare(other) < 0;
}

/**
* Check if this version is less than or equal to another
*/
lte(other: Version): boolean {
return semver.lte(this.semver, other.semver);
return this.compare(other) <= 0;
}

/**
* Check if this version equals another
*/
eq(other: Version): boolean {
return semver.eq(this.semver, other.semver);
return this.compare(other) === 0;
}

/**
* Compare with another version
* Compare with another version, mirroring 4D's Version.compareTo() logic:
* - compare major first
* - within same major: R-release > LTS (regardless of minor number)
* - then compare minor, patch, prerelease
* @returns 0 if equal, 1 if greater, -1 if less
*/
compare(other: Version): number {
return semver.compare(this.semver, other.semver);
if (this.major > other.major) return 1;
if (this.major < other.major) return -1;

// Same major: R-release beats LTS unconditionally
if (this.isR && !other.isR) return 1;
if (!this.isR && other.isR) return -1;

if (this.minor > other.minor) return 1;
if (this.minor < other.minor) return -1;

if (this.patch > other.patch) return 1;
if (this.patch < other.patch) return -1;

// Prerelease is lower precedence than release
const thisPre = this.prerelease.length > 0;
const otherPre = other.prerelease.length > 0;
if (thisPre && !otherPre) return -1;
if (!thisPre && otherPre) return 1;
if (thisPre && otherPre) {
return semver.compare(this.semver, other.semver);
}

return 0;
}

/**
Expand Down
Loading