tree_sitter_cli/
version.rs1use std::{fs, path::PathBuf, process::Command};
2
3use clap::ValueEnum;
4use log::{info, warn};
5use regex::Regex;
6use semver::Version as SemverVersion;
7use std::cmp::Ordering;
8use tree_sitter_loader::TreeSitterJSON;
9
10#[derive(Clone, Copy, Default, ValueEnum)]
11pub enum BumpLevel {
12 #[default]
13 Patch,
14 Minor,
15 Major,
16}
17
18pub struct Version {
19 pub version: Option<SemverVersion>,
20 pub current_dir: PathBuf,
21 pub bump: Option<BumpLevel>,
22}
23
24#[derive(thiserror::Error, Debug)]
25pub enum VersionError {
26 #[error(transparent)]
27 Json(#[from] serde_json::Error),
28 #[error(transparent)]
29 Io(#[from] std::io::Error),
30 #[error("Failed to update one or more files:\n\n{0}")]
31 Update(UpdateErrors),
32}
33
34#[derive(thiserror::Error, Debug)]
35pub struct UpdateErrors(Vec<UpdateError>);
36
37impl std::fmt::Display for UpdateErrors {
38 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39 for error in &self.0 {
40 writeln!(f, "{error}\n")?;
41 }
42 Ok(())
43 }
44}
45
46#[derive(thiserror::Error, Debug)]
47pub enum UpdateError {
48 #[error("Failed to update {1}:\n{0}")]
49 Io(std::io::Error, PathBuf),
50 #[error("Failed to run `{0}`:\n{1}")]
51 Command(&'static str, String),
52}
53
54impl Version {
55 #[must_use]
56 pub const fn new(
57 version: Option<SemverVersion>,
58 current_dir: PathBuf,
59 bump: Option<BumpLevel>,
60 ) -> Self {
61 Self {
62 version,
63 current_dir,
64 bump,
65 }
66 }
67
68 pub fn run(mut self) -> Result<(), VersionError> {
69 let tree_sitter_json = self.current_dir.join("tree-sitter.json");
70
71 let tree_sitter_json =
72 serde_json::from_str::<TreeSitterJSON>(&fs::read_to_string(tree_sitter_json)?)?;
73
74 let current_version = tree_sitter_json.metadata.version;
75 self.version = match (self.version.is_some(), self.bump) {
76 (false, None) => {
77 info!("Current version: {current_version}");
78 return Ok(());
79 }
80 (true, None) => self.version,
81 (false, Some(bump)) => {
82 let mut v = current_version.clone();
83 match bump {
84 BumpLevel::Patch => v.patch += 1,
85 BumpLevel::Minor => {
86 v.minor += 1;
87 v.patch = 0;
88 }
89 BumpLevel::Major => {
90 v.major += 1;
91 v.minor = 0;
92 v.patch = 0;
93 }
94 }
95 Some(v)
96 }
97 (true, Some(_)) => unreachable!(),
98 };
99
100 let new_version = self.version.as_ref().unwrap();
101 match new_version.cmp(¤t_version) {
102 Ordering::Less => {
103 warn!("New version is lower than current!");
104 warn!("Reverting version {current_version} to {new_version}");
105 }
106 Ordering::Greater => {
107 info!("Bumping version {current_version} to {new_version}");
108 }
109 Ordering::Equal => {
110 info!("Keeping version {current_version}");
111 }
112 }
113
114 let is_multigrammar = tree_sitter_json.grammars.len() > 1;
115
116 let mut errors = Vec::new();
117
118 let mut push_err = |result: Result<(), UpdateError>| -> bool {
120 if let Err(e) = result {
121 errors.push(e);
122 return true;
123 }
124 false
125 };
126
127 push_err(self.update_treesitter_json());
128
129 push_err(self.update_cargo_toml()).then(|| push_err(self.update_cargo_lock()));
131
132 push_err(self.update_package_json()).then(|| push_err(self.update_package_lock_json()));
134
135 push_err(self.update_makefile(is_multigrammar));
136 push_err(self.update_cmakelists_txt());
137 push_err(self.update_pyproject_toml());
138 push_err(self.update_zig_zon());
139
140 if errors.is_empty() {
141 Ok(())
142 } else {
143 Err(VersionError::Update(UpdateErrors(errors)))
144 }
145 }
146
147 fn update_file_with<F>(&self, path: &PathBuf, update_fn: F) -> Result<(), UpdateError>
148 where
149 F: Fn(&str) -> String,
150 {
151 let content = fs::read_to_string(path).map_err(|e| UpdateError::Io(e, path.clone()))?;
152 let updated_content = update_fn(&content);
153 fs::write(path, updated_content).map_err(|e| UpdateError::Io(e, path.clone()))
154 }
155
156 fn update_treesitter_json(&self) -> Result<(), UpdateError> {
157 let json_path = self.current_dir.join("tree-sitter.json");
158 self.update_file_with(&json_path, |content| {
159 content
160 .lines()
161 .map(|line| {
162 if line.contains("\"version\":") {
163 let prefix_index =
164 line.find("\"version\":").unwrap() + "\"version\":".len();
165 let start_quote =
166 line[prefix_index..].find('"').unwrap() + prefix_index + 1;
167 let end_quote =
168 line[start_quote + 1..].find('"').unwrap() + start_quote + 1;
169
170 format!(
171 "{}{}{}",
172 &line[..start_quote],
173 self.version.as_ref().unwrap(),
174 &line[end_quote..]
175 )
176 } else {
177 line.to_string()
178 }
179 })
180 .collect::<Vec<_>>()
181 .join("\n")
182 + "\n"
183 })
184 }
185
186 fn update_cargo_toml(&self) -> Result<(), UpdateError> {
187 let cargo_toml_path = self.current_dir.join("Cargo.toml");
188 if !cargo_toml_path.exists() {
189 return Ok(());
190 }
191
192 self.update_file_with(&cargo_toml_path, |content| {
193 content
194 .lines()
195 .map(|line| {
196 if line.starts_with("version =") {
197 format!("version = \"{}\"", self.version.as_ref().unwrap())
198 } else {
199 line.to_string()
200 }
201 })
202 .collect::<Vec<_>>()
203 .join("\n")
204 + "\n"
205 })?;
206
207 Ok(())
208 }
209
210 fn update_cargo_lock(&self) -> Result<(), UpdateError> {
211 if self.current_dir.join("Cargo.lock").exists() {
212 let Ok(cmd) = Command::new("cargo")
213 .arg("generate-lockfile")
214 .arg("--offline")
215 .current_dir(&self.current_dir)
216 .output()
217 else {
218 return Ok(()); };
220
221 if !cmd.status.success() {
222 let stderr = String::from_utf8_lossy(&cmd.stderr);
223 return Err(UpdateError::Command(
224 "cargo generate-lockfile",
225 stderr.to_string(),
226 ));
227 }
228 }
229
230 Ok(())
231 }
232
233 fn update_package_json(&self) -> Result<(), UpdateError> {
234 let package_json_path = self.current_dir.join("package.json");
235 if !package_json_path.exists() {
236 return Ok(());
237 }
238
239 self.update_file_with(&package_json_path, |content| {
240 content
241 .lines()
242 .map(|line| {
243 if line.contains("\"version\":") {
244 let prefix_index =
245 line.find("\"version\":").unwrap() + "\"version\":".len();
246 let start_quote =
247 line[prefix_index..].find('"').unwrap() + prefix_index + 1;
248 let end_quote =
249 line[start_quote + 1..].find('"').unwrap() + start_quote + 1;
250
251 format!(
252 "{}{}{}",
253 &line[..start_quote],
254 self.version.as_ref().unwrap(),
255 &line[end_quote..]
256 )
257 } else {
258 line.to_string()
259 }
260 })
261 .collect::<Vec<_>>()
262 .join("\n")
263 + "\n"
264 })?;
265
266 Ok(())
267 }
268
269 fn update_package_lock_json(&self) -> Result<(), UpdateError> {
270 if self.current_dir.join("package-lock.json").exists() {
271 let Ok(cmd) = Command::new("npm")
272 .arg("install")
273 .arg("--package-lock-only")
274 .current_dir(&self.current_dir)
275 .output()
276 else {
277 return Ok(()); };
279
280 if !cmd.status.success() {
281 let stderr = String::from_utf8_lossy(&cmd.stderr);
282 return Err(UpdateError::Command("npm install", stderr.to_string()));
283 }
284 }
285
286 Ok(())
287 }
288
289 fn update_makefile(&self, is_multigrammar: bool) -> Result<(), UpdateError> {
290 let makefile_path = if is_multigrammar {
291 self.current_dir.join("common").join("common.mak")
292 } else {
293 self.current_dir.join("Makefile")
294 };
295
296 self.update_file_with(&makefile_path, |content| {
297 content
298 .lines()
299 .map(|line| {
300 if line.starts_with("VERSION") {
301 format!("VERSION := {}", self.version.as_ref().unwrap())
302 } else {
303 line.to_string()
304 }
305 })
306 .collect::<Vec<_>>()
307 .join("\n")
308 + "\n"
309 })?;
310
311 Ok(())
312 }
313
314 fn update_cmakelists_txt(&self) -> Result<(), UpdateError> {
315 let cmake_lists_path = self.current_dir.join("CMakeLists.txt");
316 if !cmake_lists_path.exists() {
317 return Ok(());
318 }
319
320 self.update_file_with(&cmake_lists_path, |content| {
321 let re = Regex::new(r#"(\s*VERSION\s+)"[0-9]+\.[0-9]+\.[0-9]+""#)
322 .expect("Failed to compile regex");
323 re.replace(
324 content,
325 format!(r#"$1"{}""#, self.version.as_ref().unwrap()),
326 )
327 .to_string()
328 })?;
329
330 Ok(())
331 }
332
333 fn update_pyproject_toml(&self) -> Result<(), UpdateError> {
334 let pyproject_toml_path = self.current_dir.join("pyproject.toml");
335 if !pyproject_toml_path.exists() {
336 return Ok(());
337 }
338
339 self.update_file_with(&pyproject_toml_path, |content| {
340 content
341 .lines()
342 .map(|line| {
343 if line.starts_with("version =") {
344 format!("version = \"{}\"", self.version.as_ref().unwrap())
345 } else {
346 line.to_string()
347 }
348 })
349 .collect::<Vec<_>>()
350 .join("\n")
351 + "\n"
352 })?;
353
354 Ok(())
355 }
356
357 fn update_zig_zon(&self) -> Result<(), UpdateError> {
358 let zig_zon_path = self.current_dir.join("build.zig.zon");
359 if !zig_zon_path.exists() {
360 return Ok(());
361 }
362
363 self.update_file_with(&zig_zon_path, |content| {
364 let zig_version_prefix = ".version =";
365 content
366 .lines()
367 .map(|line| {
368 if line
369 .trim_start_matches(|c: char| c.is_ascii_whitespace())
370 .starts_with(zig_version_prefix)
371 {
372 let prefix_index =
373 line.find(zig_version_prefix).unwrap() + zig_version_prefix.len();
374 let start_quote =
375 line[prefix_index..].find('"').unwrap() + prefix_index + 1;
376 let end_quote =
377 line[start_quote + 1..].find('"').unwrap() + start_quote + 1;
378
379 format!(
380 "{}{}{}",
381 &line[..start_quote],
382 self.version.as_ref().unwrap(),
383 &line[end_quote..]
384 )
385 } else {
386 line.to_string()
387 }
388 })
389 .collect::<Vec<_>>()
390 .join("\n")
391 + "\n"
392 })?;
393
394 Ok(())
395 }
396}