Skip to main content

veryl_metadata/
metadata.rs

1use crate::build::{Build, Target};
2use crate::build_info::BuildInfo;
3use crate::doc::Doc;
4use crate::format::Format;
5use crate::git::Git;
6use crate::lint::Lint;
7use crate::lockfile::Lockfile;
8use crate::project::Project;
9use crate::pubfile::{Pubfile, Release};
10use crate::publish::Publish;
11use crate::synth::Synth;
12use crate::test::Test;
13use crate::{FilelistType, MetadataError, SourceMapTarget};
14use log::{debug, info, warn};
15use once_cell::sync::Lazy;
16use regex::Regex;
17use semver::VersionReq;
18use serde::{Deserialize, Serialize};
19use spdx::Expression;
20use std::collections::HashMap;
21use std::env;
22use std::fmt;
23use std::fs;
24use std::path::{Path, PathBuf};
25use std::str::FromStr;
26use std::time::SystemTime;
27use url::Url;
28use veryl_path::{PathSet, ignore_already_exists};
29
30#[derive(Clone, Copy, Debug)]
31pub enum BumpKind {
32    Major,
33    Minor,
34    Patch,
35}
36
37#[derive(Clone, Debug, Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct Metadata {
40    pub project: Project,
41    #[serde(default)]
42    pub build: Build,
43    #[serde(default)]
44    pub format: Format,
45    #[serde(default)]
46    pub lint: Lint,
47    #[serde(default)]
48    pub publish: Publish,
49    #[serde(default)]
50    pub doc: Doc,
51    #[serde(default)]
52    pub test: Test,
53    #[serde(default)]
54    pub synth: Synth,
55    #[serde(default)]
56    pub dependencies: HashMap<String, Dependency>,
57    #[serde(skip)]
58    pub metadata_path: PathBuf,
59    #[serde(skip)]
60    pub pubfile_path: PathBuf,
61    #[serde(skip)]
62    pub pubfile: Pubfile,
63    #[serde(skip)]
64    pub lockfile_path: PathBuf,
65    #[serde(skip)]
66    pub lockfile: Lockfile,
67    #[serde(skip)]
68    pub build_info: BuildInfo,
69}
70
71#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq, PartialOrd, Ord)]
72#[serde(untagged)]
73pub enum UrlPath {
74    Url(Url),
75    Path(PathBuf),
76}
77
78impl fmt::Display for UrlPath {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        match self {
81            UrlPath::Url(x) => x.fmt(f),
82            UrlPath::Path(x) => {
83                let text = x.to_string_lossy();
84                text.fmt(f)
85            }
86        }
87    }
88}
89
90static VALID_PROJECT_NAME: Lazy<Regex> =
91    Lazy::new(|| Regex::new(r"^[a-zA-Z_][0-9a-zA-Z_]*$").unwrap());
92
93fn check_project_name(name: &str) -> Result<(), MetadataError> {
94    if !VALID_PROJECT_NAME.is_match(name) {
95        return Err(MetadataError::InvalidProjectName(name.to_string()));
96    }
97    if name.starts_with("__") {
98        return Err(MetadataError::ReservedProjectName(name.to_string()));
99    }
100    Ok(())
101}
102
103impl Metadata {
104    pub fn search_from_current() -> Result<PathBuf, MetadataError> {
105        Metadata::search_from(
106            env::current_dir().map_err(|x| MetadataError::file_io(x, &PathBuf::from(".")))?,
107        )
108    }
109
110    pub fn search_from<T: AsRef<Path>>(from: T) -> Result<PathBuf, MetadataError> {
111        for path in from.as_ref().ancestors() {
112            let path = path.join("Veryl.toml");
113            if path.is_file() {
114                return Ok(path);
115            }
116        }
117
118        Err(MetadataError::FileNotFound)
119    }
120
121    pub fn load<T: AsRef<Path>>(path: T) -> Result<Self, MetadataError> {
122        let path = path
123            .as_ref()
124            .canonicalize()
125            .map_err(|x| MetadataError::file_io(x, path.as_ref()))?;
126        let text = fs::read_to_string(&path).map_err(|x| MetadataError::file_io(x, &path))?;
127        let mut metadata: Metadata = Self::from_str(&text)?;
128        metadata.metadata_path.clone_from(&path);
129        metadata.pubfile_path = path.with_file_name("Veryl.pub");
130        metadata.lockfile_path = path.with_file_name("Veryl.lock");
131        metadata.check()?;
132
133        if metadata.pubfile_path.exists() {
134            metadata.pubfile = Pubfile::load(&metadata.pubfile_path)?;
135        }
136
137        let dot_build = metadata.project_dot_build_path();
138        if !dot_build.exists() {
139            ignore_already_exists(fs::create_dir(&dot_build))
140                .map_err(|x| MetadataError::file_io(x, &dot_build))?;
141        }
142
143        let build_info = metadata.project_build_info_path();
144        if build_info.exists() {
145            if let Ok(info) = BuildInfo::load(&build_info) {
146                metadata.build_info = info;
147            } else {
148                // ignore failure of loading BuildInfo
149                info!("Discarded incompatible .build/info.toml");
150            }
151        }
152
153        debug!(
154            "Loaded metadata ({})",
155            metadata.metadata_path.to_string_lossy()
156        );
157        Ok(metadata)
158    }
159
160    pub fn publish(&mut self) -> Result<(), MetadataError> {
161        let prj_path = self.project_path();
162        let git = Git::open(&prj_path)?;
163        if !git.is_clean()? {
164            return Err(MetadataError::ModifiedProject(prj_path.to_path_buf()));
165        }
166
167        let version = self
168            .project
169            .version
170            .clone()
171            .ok_or(MetadataError::MissingVersion)?;
172
173        for release in &self.pubfile.releases {
174            if release.version == version {
175                return Err(MetadataError::PublishedVersion(version));
176            }
177        }
178
179        let revision = git.get_revision()?;
180
181        info!("Publishing release ({version} @ {revision})");
182
183        let release = Release { version, revision };
184
185        self.pubfile.releases.push(release);
186
187        self.pubfile.save(&self.pubfile_path)?;
188        info!("Writing metadata ({})", self.pubfile_path.to_string_lossy());
189
190        if self.publish.publish_commit {
191            git.add(&self.pubfile_path)?;
192            git.commit(&self.publish.publish_commit_message)?;
193            info!(
194                "Committing metadata ({})",
195                self.pubfile_path.to_string_lossy()
196            );
197        }
198
199        Ok(())
200    }
201
202    pub fn check(&self) -> Result<(), MetadataError> {
203        check_project_name(&self.project.name)?;
204
205        if let Some(ref license) = self.project.license {
206            let _ = Expression::parse(license)?;
207        }
208
209        Ok(())
210    }
211
212    pub fn bump_version(&mut self, kind: BumpKind) -> Result<(), MetadataError> {
213        let prj_path = self.project_path();
214        let git = Git::open(&prj_path)?;
215
216        let current_version = self
217            .project
218            .version
219            .as_ref()
220            .ok_or(MetadataError::MissingVersion)?;
221
222        let mut bumped_version = current_version.clone();
223
224        match kind {
225            BumpKind::Major => {
226                bumped_version.major += 1;
227                bumped_version.minor = 0;
228                bumped_version.patch = 0;
229            }
230            BumpKind::Minor => {
231                bumped_version.minor += 1;
232                bumped_version.patch = 0;
233            }
234            BumpKind::Patch => bumped_version.patch += 1,
235        }
236        info!(
237            "Bumping version ({} -> {})",
238            current_version, bumped_version
239        );
240
241        self.project.version = Some(bumped_version.clone());
242
243        let toml = fs::read_to_string(&self.metadata_path)
244            .map_err(|x| MetadataError::file_io(x, &self.metadata_path))?;
245        let re = Regex::new(r#"version\s+=\s+"([^"]*)""#).unwrap();
246        let caps = re
247            .captures(&toml)
248            .expect("safely unwrap because metadata is valid");
249        let bumped_field = caps[0].replace(&caps[1], &bumped_version.to_string());
250        let bumped_toml = re.replace(&toml, bumped_field);
251        fs::write(&self.metadata_path, bumped_toml.as_bytes())
252            .map_err(|x| MetadataError::file_io(x, &self.metadata_path))?;
253        info!(
254            "Updating version field ({})",
255            self.metadata_path.to_string_lossy()
256        );
257
258        if self.publish.bump_commit {
259            git.add(&self.metadata_path)?;
260            git.commit(&self.publish.bump_commit_message)?;
261            info!(
262                "Committing metadata ({})",
263                self.metadata_path.to_string_lossy()
264            );
265        }
266
267        Ok(())
268    }
269
270    pub fn update_lockfile(&mut self) -> Result<(), MetadataError> {
271        let modified = if self.lockfile_path.exists() {
272            let mut lockfile = Lockfile::load(self)?;
273            let modified = lockfile.update(self, false)?;
274            self.lockfile = lockfile;
275            modified
276        } else {
277            self.lockfile = Lockfile::new(self)?;
278            true
279        };
280        if modified {
281            self.lockfile.save(&self.lockfile_path)?;
282        }
283        Ok(())
284    }
285
286    pub fn save_build_info(&mut self) -> Result<(), MetadataError> {
287        let build_info = self.project_build_info_path();
288        self.build_info.save(&build_info)
289    }
290
291    pub fn add_generated_file(&mut self, path: PathBuf) {
292        self.build_info
293            .generated_files
294            .insert(path, SystemTime::now());
295    }
296
297    pub fn paths<T: AsRef<Path>>(
298        &mut self,
299        files: &[T],
300        symlink: bool,
301        include_dependencies: bool,
302    ) -> Result<Vec<PathSet>, MetadataError> {
303        let sources = if self.build.source.iter().count() > 0 {
304            warn!(
305                "[Veryl.toml] \"source\" field is deprecated. Replace it with \"sources\" field."
306            );
307            vec![self.build.source.clone()]
308        } else {
309            self.build.sources.clone()
310        };
311
312        let base = self.project_path();
313        let mut ret = Vec::new();
314
315        // Pre-canonicalize explicit file args once so we can route each to
316        // the source dir it actually belongs to (without re-processing the
317        // same file for every configured source dir).
318        let canonical_files = if files.is_empty() {
319            None
320        } else {
321            let mut v = Vec::new();
322            for file in files {
323                v.push(
324                    fs::canonicalize(file.as_ref())
325                        .map_err(|x| MetadataError::file_io(x, file.as_ref()))?,
326                );
327            }
328            Some(v)
329        };
330        let mut explicit_routed = canonical_files.as_ref().map(|v| vec![false; v.len()]);
331
332        for source in &sources {
333            let src_base = base.join(source);
334
335            let src_files = if let Some(cf) = canonical_files.as_ref() {
336                // Only keep files that live under this source dir; other
337                // source dirs in `sources` will pick them up.
338                let mut ret = Vec::new();
339                for (i, path) in cf.iter().enumerate() {
340                    if path.starts_with(&src_base) {
341                        ret.push(path.clone());
342                        if let Some(ref mut flags) = explicit_routed {
343                            flags[i] = true;
344                        }
345                    }
346                }
347                ret
348            } else {
349                veryl_path::gather_files_with_extension(&src_base, "veryl", symlink)?
350            };
351
352            for src in src_files {
353                let Ok(src_relative) = src.strip_prefix(&src_base) else {
354                    return Err(MetadataError::InvalidSourceLocation(src));
355                };
356                let dst = match self.build.target {
357                    Target::Source => src.with_extension("sv"),
358                    Target::Directory { ref path } => {
359                        base.join(path.join(src_relative.with_extension("sv")))
360                    }
361                    Target::Bundle { .. } => base.join(
362                        PathBuf::from("target").join(src.with_extension("sv").file_name().unwrap()),
363                    ),
364                };
365                let map = match &self.build.sourcemap_target {
366                    SourceMapTarget::Directory { path } => {
367                        if let Target::Directory { .. } = self.build.target {
368                            base.join(path.join(src_relative.with_extension("sv.map")))
369                        } else {
370                            let dst = dst.strip_prefix(&base).unwrap();
371                            base.join(path.join(dst.with_extension("sv.map")))
372                        }
373                    }
374                    _ => {
375                        let mut map = dst.clone();
376                        map.set_extension("sv.map");
377                        map
378                    }
379                };
380                ret.push(PathSet {
381                    prj: self.project.name.clone(),
382                    src: src.to_path_buf(),
383                    dst,
384                    map,
385                });
386            }
387        }
388
389        // Any explicit file that wasn't claimed by a configured source dir
390        // is outside the project — preserve the original error semantics.
391        if let (Some(cf), Some(flags)) = (canonical_files.as_ref(), explicit_routed.as_ref())
392            && let Some(pos) = flags.iter().position(|f| !f)
393        {
394            return Err(MetadataError::InvalidSourceLocation(cf[pos].clone()));
395        }
396
397        let base_dst = self.project_dependencies_path();
398        if !base_dst.exists() {
399            ignore_already_exists(fs::create_dir(&base_dst))
400                .map_err(|x| MetadataError::file_io(x, &base_dst))?;
401        }
402
403        if include_dependencies {
404            if !self.build.exclude_std {
405                veryl_std::expand()?;
406                ret.append(&mut veryl_std::paths(&base_dst)?);
407            }
408
409            self.update_lockfile()?;
410
411            let mut deps = self.lockfile.paths(&base_dst)?;
412            ret.append(&mut deps);
413        }
414
415        Ok(ret)
416    }
417
418    pub fn create_default_toml(name: &str) -> Result<String, MetadataError> {
419        check_project_name(name)?;
420
421        Ok(format!(
422            r###"[project]
423name = "{name}"
424version = "0.1.0"
425[build]
426sources = ["src"]
427target = {{type = "directory", path = "target"}}"###
428        ))
429    }
430
431    pub fn create_default(name: &str) -> Result<Metadata, MetadataError> {
432        let metadata: Metadata = toml::from_str(&Self::create_default_toml(name)?)?;
433        Ok(metadata)
434    }
435
436    pub fn create_default_gitignore() -> &'static str {
437        r#"# Build output
438.build/
439/target
440/dependencies
441*.f
442
443# Verilator
444obj_dir/
445"#
446    }
447
448    pub fn project_path(&self) -> PathBuf {
449        self.metadata_path.parent().unwrap().to_path_buf()
450    }
451
452    pub fn project_dependencies_path(&self) -> PathBuf {
453        self.project_path().join("dependencies")
454    }
455
456    pub fn project_dot_build_path(&self) -> PathBuf {
457        self.project_path().join(".build")
458    }
459
460    pub fn project_build_info_path(&self) -> PathBuf {
461        self.project_dot_build_path().join("info.toml")
462    }
463
464    pub fn filelist_path(&self) -> PathBuf {
465        let filelist_name = match self.build.filelist_type {
466            FilelistType::Absolute => format!("{}.f", self.project.name),
467            FilelistType::Relative => format!("{}.f", self.project.name),
468            FilelistType::Flgen => format!("{}.list.rb", self.project.name),
469        };
470
471        self.metadata_path.with_file_name(filelist_name)
472    }
473
474    pub fn doc_path(&self) -> PathBuf {
475        self.metadata_path.parent().unwrap().join(&self.doc.path)
476    }
477}
478
479impl FromStr for Metadata {
480    type Err = MetadataError;
481
482    fn from_str(s: &str) -> Result<Self, Self::Err> {
483        let metadata: Metadata = toml::from_str(s)?;
484        Ok(metadata)
485    }
486}
487
488#[derive(Clone, Debug, Serialize, Deserialize)]
489#[serde(untagged)]
490#[serde(deny_unknown_fields)]
491pub enum Dependency {
492    Version(VersionReq),
493    Entry(DependencyEntry),
494}
495
496#[derive(Clone, Debug, Serialize, Deserialize)]
497#[serde(deny_unknown_fields)]
498pub struct DependencyEntry {
499    pub version: Option<VersionReq>,
500    pub git: Option<UrlPath>,
501    pub github: Option<String>,
502    pub project: Option<String>,
503    pub path: Option<PathBuf>,
504}