tree_sitter_cli/
version.rs

1use 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(&current_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        // Helper to push errors into the errors vector, returns true if an error was pushed
119        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        // Only update Cargo.lock if Cargo.toml was updated
130        push_err(self.update_cargo_toml()).then(|| push_err(self.update_cargo_lock()));
131
132        // Only update package-lock.json if package.json was updated
133        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(()); // cargo is not `executable`, ignore
219            };
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(()); // npm is not `executable`, ignore
278            };
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}