Skip to content

Commit 86c0fa7

Browse files
committed
perf(core): optimise version specifier parsing
1 parent d522b51 commit 86c0fa7

File tree

3 files changed

+142
-83
lines changed

3 files changed

+142
-83
lines changed

src/specifier.rs

Lines changed: 141 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -55,16 +55,21 @@ fn determine_semver_range(value: &str) -> Option<SemverRange> {
5555
}
5656

5757
/// Normalise values which are needlessly different
58-
fn sanitise_value(value: &str) -> String {
58+
/// Returns None if no changes needed, Some(String) if modified
59+
fn sanitise_value(value: &str) -> Option<String> {
5960
if value == "latest" || value == "x" {
60-
"*".to_string()
61-
} else {
62-
let value = value.replace(".x", "").replace(".*", "");
63-
if value.starts_with("v") {
64-
value.chars().skip(1).collect()
61+
Some("*".to_string())
62+
} else if value.contains(".x") || value.contains(".*") {
63+
let sanitised = value.replace(".x", "").replace(".*", "");
64+
if sanitised.starts_with('v') {
65+
Some(sanitised.chars().skip(1).collect())
6566
} else {
66-
value
67+
Some(sanitised)
6768
}
69+
} else if value.starts_with('v') {
70+
Some(value.chars().skip(1).collect())
71+
} else {
72+
None
6873
}
6974
}
7075

@@ -83,98 +88,146 @@ pub enum Specifier {
8388
}
8489

8590
impl Specifier {
86-
/// Create a new instance
8791
pub fn new(value: &str, local_version: Option<&BasicSemver>) -> Self {
88-
let raw = value.to_string();
89-
90-
if parser::is_workspace_protocol(value) {
91-
return Self::from_workspace_protocol(value, local_version, raw);
92-
} else if parser::is_alias(value) {
93-
return Self::from_alias(value, raw);
94-
} else if parser::is_git(value) {
95-
return Self::from_git(value, raw);
96-
} else if parser::is_file(value) {
97-
return Self::File(raw::Raw { raw });
98-
} else if parser::is_url(value) {
99-
return Self::Url(raw::Raw { raw });
92+
let first_char = value.chars().next().unwrap_or('\0');
93+
94+
if first_char.is_ascii_digit()
95+
|| first_char == '^'
96+
|| first_char == '~'
97+
|| first_char == '>'
98+
|| first_char == '<'
99+
|| first_char == '*'
100+
|| (first_char == 'l' && value == "latest")
101+
|| (first_char == 'x' && value == "x")
102+
{
103+
let sanitised = sanitise_value(value);
104+
let sanitised_str = sanitised.as_deref().unwrap_or(value);
105+
match Range::parse(sanitised_str) {
106+
Ok(node_range) => {
107+
if parser::is_complex_range(sanitised_str) {
108+
return Self::ComplexSemver(ComplexSemver {
109+
raw: value.to_string(),
110+
node_range,
111+
});
112+
} else {
113+
match BasicSemver::new(sanitised_str) {
114+
Some(semver) => return Self::BasicSemver(semver),
115+
None => return Self::Unsupported(raw::Raw { raw: value.to_string() }),
116+
}
117+
}
118+
}
119+
Err(_) => return Self::Unsupported(raw::Raw { raw: value.to_string() }),
120+
}
121+
}
122+
123+
if first_char == 'w' && value.starts_with("workspace:") {
124+
return Self::from_workspace_protocol(value, local_version, value.to_string());
125+
}
126+
if first_char == 'n' && value.starts_with("npm:") {
127+
return Self::from_alias(value, value.to_string());
128+
}
129+
if first_char == 'g' && parser::is_git(value) {
130+
return Self::from_git(value, value.to_string());
131+
}
132+
if first_char == 'f' && value.starts_with("file:") {
133+
return Self::File(raw::Raw { raw: value.to_string() });
134+
}
135+
if first_char == 'h' && (value.starts_with("http://") || value.starts_with("https://")) {
136+
return Self::Url(raw::Raw { raw: value.to_string() });
100137
}
101138

139+
// Handle remaining cases (tags, etc.)
102140
let sanitised = sanitise_value(value);
103-
let value = sanitised.as_str();
141+
let sanitised_str = sanitised.as_deref().unwrap_or(value);
104142

105-
if parser::is_tag(value) {
106-
return Self::Tag(raw::Raw { raw });
143+
// Check if it's a tag (alphabetic only)
144+
if parser::is_tag(sanitised_str) {
145+
return Self::Tag(raw::Raw { raw: value.to_string() });
107146
}
108147

109-
match Range::parse(value) {
148+
// Final fallback - try to parse as semver range anyway
149+
match Range::parse(sanitised_str) {
110150
Ok(node_range) => {
111-
if parser::is_complex_range(value) {
112-
Self::ComplexSemver(ComplexSemver { raw, node_range })
151+
if parser::is_complex_range(sanitised_str) {
152+
Self::ComplexSemver(ComplexSemver {
153+
raw: value.to_string(),
154+
node_range,
155+
})
113156
} else {
114-
match BasicSemver::new(value) {
157+
match BasicSemver::new(sanitised_str) {
115158
Some(semver) => Self::BasicSemver(semver),
116-
None => Self::Unsupported(raw::Raw { raw }),
159+
None => Self::Unsupported(raw::Raw { raw: value.to_string() }),
117160
}
118161
}
119162
}
120-
Err(_) => Self::Unsupported(raw::Raw { raw }),
163+
Err(_) => Self::Unsupported(raw::Raw { raw: value.to_string() }),
121164
}
122165
}
123166

124167
/// Create a new instance from a specifier containing "workspace:"
125168
fn from_workspace_protocol(value: &str, local_version: Option<&BasicSemver>, raw: String) -> Self {
126169
local_version
127170
.and_then(|local| {
128-
let without_protocol = value.replace("workspace:", "");
129-
let sanitised = sanitise_value(&without_protocol);
130-
if parser::is_simple_semver(&sanitised) {
171+
// Skip "workspace:" prefix (10 chars) to avoid allocation
172+
let without_protocol = &value[10..];
173+
let sanitised = sanitise_value(without_protocol);
174+
let sanitised_str = sanitised.as_deref().unwrap_or(without_protocol);
175+
176+
if parser::is_simple_semver(sanitised_str) {
131177
Some(Self::WorkspaceProtocol(WorkspaceProtocol {
132-
raw: format!("workspace:{sanitised}"),
178+
raw: if sanitised.is_some() {
179+
format!("workspace:{sanitised_str}")
180+
} else {
181+
value.to_string()
182+
},
133183
local_version: local.clone(),
134-
semver: BasicSemver::new(&sanitised).unwrap(),
184+
semver: BasicSemver::new(sanitised_str).unwrap(),
135185
}))
136-
} else if sanitised == "~" || sanitised == "^" {
186+
} else if sanitised_str == "~" || sanitised_str == "^" {
187+
let combined_version = format!("{}{}", sanitised_str, local.raw);
137188
Some(Self::WorkspaceProtocol(WorkspaceProtocol {
138-
raw: format!("workspace:{sanitised}"),
189+
raw: format!("workspace:{sanitised_str}"),
139190
local_version: local.clone(),
140-
semver: BasicSemver::new(&format!("{}{}", sanitised, local.raw)).unwrap(),
191+
semver: BasicSemver::new(&combined_version).unwrap(),
141192
}))
142193
} else {
143194
None
144195
}
145196
})
146-
.unwrap_or_else(|| Self::Unsupported(raw::Raw { raw: raw.clone() }))
197+
.unwrap_or(Self::Unsupported(raw::Raw { raw }))
147198
}
148199

149200
/// Create a new instance from an npm alias specifier
150201
fn from_alias(value: &str, raw: String) -> Self {
151-
let (aliased_name, aliased_version) = {
152-
let start = value.find(':').unwrap() + 1;
153-
if let Some(at_pos) = value.rfind('@') {
154-
if at_pos > start {
155-
// There's a version specifier
156-
(value[start..at_pos].to_string(), value[at_pos + 1..].to_string())
157-
} else {
158-
// The @ is part of a scoped package name, no version
159-
(value[start..].to_string(), String::new())
160-
}
202+
// Skip "npm:" prefix (4 chars) to avoid allocation
203+
let after_prefix = &value[4..];
204+
205+
let (aliased_name, aliased_version) = if let Some(at_pos) = after_prefix.rfind('@') {
206+
// Check if this @ is actually a version separator (not part of scoped name)
207+
if at_pos > 0 && !after_prefix[..at_pos].is_empty() {
208+
// There's a version specifier
209+
(&after_prefix[..at_pos], &after_prefix[at_pos + 1..])
161210
} else {
162-
// No @ at all, unscoped package without version
163-
(value[start..].to_string(), String::new())
211+
// The @ is part of a scoped package name, no version
212+
(after_prefix, "")
164213
}
214+
} else {
215+
// No @ at all, unscoped package without version
216+
(after_prefix, "")
165217
};
218+
166219
if aliased_name.is_empty() {
167220
Self::Unsupported(raw::Raw { raw })
168221
} else if aliased_version.is_empty() {
169222
Self::Alias(alias::Alias {
170223
raw,
171-
name: aliased_name,
224+
name: aliased_name.to_string(),
172225
semver: None,
173226
})
174-
} else if let Self::BasicSemver(inner) = Self::new(&aliased_version, None) {
227+
} else if let Self::BasicSemver(inner) = Self::new(aliased_version, None) {
175228
Self::Alias(alias::Alias {
176229
raw,
177-
name: aliased_name,
230+
name: aliased_name.to_string(),
178231
semver: Some(inner),
179232
})
180233
} else {
@@ -185,22 +238,44 @@ impl Specifier {
185238
/// Create a new instance from a git specifier, this can be a git url or some
186239
/// kind of github shorthand
187240
fn from_git(value: &str, raw: String) -> Self {
188-
let parts = value.split('#').collect::<Vec<&str>>();
189-
let git_tag = parts.get(1).map(|tag| tag.to_string()).unwrap_or_default();
190-
let git_tag = sanitise_value(&git_tag);
191-
let origin = parts.first().map(|origin| origin.to_string()).unwrap_or_default();
192-
if origin.is_empty() {
193-
Self::Unsupported(raw::Raw { raw })
194-
} else if git_tag.is_empty() {
195-
Self::Git(git::Git { raw, origin, semver: None })
196-
} else if let Some(inner) = BasicSemver::new(&git_tag) {
241+
if let Some(hash_pos) = value.find('#') {
242+
let origin = &value[..hash_pos];
243+
let git_tag_str = &value[hash_pos + 1..];
244+
245+
if origin.is_empty() {
246+
return Self::Unsupported(raw::Raw { raw });
247+
}
248+
249+
if git_tag_str.is_empty() {
250+
Self::Git(git::Git {
251+
raw,
252+
origin: origin.to_string(),
253+
semver: None,
254+
})
255+
} else {
256+
let sanitised_tag = sanitise_value(git_tag_str);
257+
let tag_str = sanitised_tag.as_deref().unwrap_or(git_tag_str);
258+
if let Some(inner) = BasicSemver::new(tag_str) {
259+
Self::Git(git::Git {
260+
raw,
261+
origin: origin.to_string(),
262+
semver: Some(inner),
263+
})
264+
} else {
265+
Self::Git(git::Git {
266+
raw,
267+
origin: origin.to_string(),
268+
semver: None,
269+
})
270+
}
271+
}
272+
} else {
273+
// No hash, just the origin
197274
Self::Git(git::Git {
198275
raw,
199-
origin,
200-
semver: Some(inner),
276+
origin: value.to_string(),
277+
semver: None,
201278
})
202-
} else {
203-
Self::Git(git::Git { raw, origin, semver: None })
204279
}
205280
}
206281

src/specifier/basic_semver.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ impl BasicSemver {
5555
} else if parser::is_range(value) {
5656
let range_variant = determine_semver_range(value).unwrap();
5757
let exact = get_raw_without_range(value);
58-
let node_version = Version::parse(exact).unwrap();
58+
let node_version = Version::parse(&exact).unwrap();
5959
Some(BasicSemver {
6060
raw,
6161
variant: BasicSemverVariant::Patch,

src/specifier/parser.rs

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -75,22 +75,6 @@ pub fn is_tag(str: &str) -> bool {
7575
regexes::TAG.is_match(str)
7676
}
7777

78-
pub fn is_workspace_protocol(str: &str) -> bool {
79-
regexes::WORKSPACE_PROTOCOL.is_match(str)
80-
}
81-
82-
pub fn is_alias(str: &str) -> bool {
83-
regexes::ALIAS.is_match(str)
84-
}
85-
8678
pub fn is_git(str: &str) -> bool {
8779
regexes::GIT.is_match(str)
8880
}
89-
90-
pub fn is_url(str: &str) -> bool {
91-
regexes::URL.is_match(str)
92-
}
93-
94-
pub fn is_file(str: &str) -> bool {
95-
regexes::FILE.is_match(str)
96-
}

0 commit comments

Comments
 (0)