Skip to main content

veryl_metadata/
metadata.rs

1use crate::build::{Build, Target};
2use crate::build_info::BuildInfo;
3use crate::component::Component;
4use crate::doc::Doc;
5use crate::format::Format;
6use crate::git::Git;
7use crate::lint::Lint;
8use crate::lockfile::Lockfile;
9use crate::project::Project;
10use crate::pubfile::{Pubfile, Release};
11use crate::publish::Publish;
12use crate::synth::Synth;
13use crate::test::Test;
14use crate::{FilelistType, MetadataError, SourceMapTarget};
15use log::{debug, info, warn};
16use once_cell::sync::Lazy;
17use regex::Regex;
18use semver::VersionReq;
19use serde::{Deserialize, Serialize};
20use spdx::Expression;
21use std::collections::{BTreeMap, HashMap};
22use std::env;
23use std::fmt;
24use std::fs;
25use std::path::{Path, PathBuf};
26use std::str::FromStr;
27use std::time::SystemTime;
28use url::Url;
29use veryl_path::{PathSet, ignore_already_exists};
30
31#[derive(Clone, Copy, Debug)]
32pub enum BumpKind {
33    Major,
34    Minor,
35    Patch,
36}
37
38#[derive(Clone, Debug, Serialize, Deserialize)]
39#[serde(deny_unknown_fields)]
40pub struct Metadata {
41    pub project: Project,
42    #[serde(default)]
43    pub build: Build,
44    #[serde(default)]
45    pub format: Format,
46    #[serde(default)]
47    pub lint: Lint,
48    #[serde(default)]
49    pub publish: Publish,
50    #[serde(default)]
51    pub doc: Doc,
52    #[serde(default)]
53    pub test: Test,
54    #[serde(default)]
55    pub synth: Synth,
56    #[serde(default)]
57    pub properties: BTreeMap<String, ProjectProperty>,
58    #[serde(default)]
59    pub components: Vec<Component>,
60    #[serde(default)]
61    pub dependencies: HashMap<String, Dependency>,
62    #[serde(default)]
63    pub metadata: HashMap<String, toml::Value>,
64    #[serde(skip)]
65    pub metadata_path: PathBuf,
66    #[serde(skip)]
67    pub pubfile_path: PathBuf,
68    #[serde(skip)]
69    pub pubfile: Pubfile,
70    #[serde(skip)]
71    pub lockfile_path: PathBuf,
72    #[serde(skip)]
73    pub lockfile: Lockfile,
74    #[serde(skip)]
75    pub build_info: BuildInfo,
76    /// Output directory override (e.g. `veryl build --out-dir`).
77    /// When set, build outputs are emitted relative to this directory
78    /// instead of the project path. Never read from Veryl.toml.
79    #[serde(skip)]
80    pub output_dir_override: Option<PathBuf>,
81}
82
83#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq, PartialOrd, Ord)]
84#[serde(untagged)]
85pub enum ProjectProperty {
86    Int(i64),
87    Bool(bool),
88}
89
90impl ProjectProperty {
91    pub fn is_compatible(&self, other: &ProjectProperty) -> bool {
92        matches!(
93            (self, other),
94            (ProjectProperty::Int(_), ProjectProperty::Int(_))
95                | (ProjectProperty::Bool(_), ProjectProperty::Bool(_))
96        )
97    }
98
99    pub fn type_name(&self) -> String {
100        match self {
101            ProjectProperty::Int(_) => "int".to_string(),
102            ProjectProperty::Bool(_) => "bool".to_string(),
103        }
104    }
105
106    pub fn value_string(&self) -> String {
107        match self {
108            ProjectProperty::Int(x) => x.to_string(),
109            ProjectProperty::Bool(x) => x.to_string(),
110        }
111    }
112
113    pub fn verilog_value_string(&self) -> String {
114        match self {
115            ProjectProperty::Int(x) => x.to_string(),
116            ProjectProperty::Bool(x) => (if *x { "1'b1" } else { "1'b0" }).to_string(),
117        }
118    }
119}
120
121#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq, PartialOrd, Ord)]
122#[serde(untagged)]
123pub enum UrlPath {
124    Url(Url),
125    Path(PathBuf),
126}
127
128impl fmt::Display for UrlPath {
129    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130        match self {
131            UrlPath::Url(x) => x.fmt(f),
132            UrlPath::Path(x) => {
133                let text = x.to_string_lossy();
134                text.fmt(f)
135            }
136        }
137    }
138}
139
140static VALID_PROJECT_NAME: Lazy<Regex> =
141    Lazy::new(|| Regex::new(r"^[a-zA-Z_][0-9a-zA-Z_]*$").unwrap());
142
143/// Validates a project or component name: identifiers only, `__` reserved.
144pub fn check_project_name(name: &str) -> Result<(), MetadataError> {
145    if !VALID_PROJECT_NAME.is_match(name) {
146        return Err(MetadataError::InvalidProjectName(name.to_string()));
147    }
148    if name.starts_with("__") {
149        return Err(MetadataError::ReservedProjectName(name.to_string()));
150    }
151    Ok(())
152}
153
154impl Metadata {
155    pub fn search_from_current() -> Result<PathBuf, MetadataError> {
156        Metadata::search_from(
157            env::current_dir().map_err(|x| MetadataError::file_io(x, &PathBuf::from(".")))?,
158        )
159    }
160
161    pub fn search_from<T: AsRef<Path>>(from: T) -> Result<PathBuf, MetadataError> {
162        for path in from.as_ref().ancestors() {
163            let path = path.join("Veryl.toml");
164            if path.is_file() {
165                return Ok(path);
166            }
167        }
168
169        Err(MetadataError::FileNotFound(from.as_ref().to_path_buf()))
170    }
171
172    pub fn load<T: AsRef<Path>>(path: T) -> Result<Self, MetadataError> {
173        let path = path
174            .as_ref()
175            .canonicalize()
176            .map_err(|x| MetadataError::file_io(x, path.as_ref()))?;
177        let text = fs::read_to_string(&path).map_err(|x| MetadataError::file_io(x, &path))?;
178        let mut metadata: Metadata = Self::from_str(&text)?;
179        metadata.metadata_path.clone_from(&path);
180        metadata.pubfile_path = path.with_file_name("Veryl.pub");
181        metadata.lockfile_path = path.with_file_name("Veryl.lock");
182        metadata.check()?;
183
184        if metadata.pubfile_path.exists() {
185            metadata.pubfile = Pubfile::load(&metadata.pubfile_path)?;
186        }
187
188        let dot_build = metadata.project_dot_build_path();
189        if !dot_build.exists() {
190            ignore_already_exists(fs::create_dir(&dot_build))
191                .map_err(|x| MetadataError::file_io(x, &dot_build))?;
192        }
193
194        let build_info = metadata.project_build_info_path();
195        if build_info.exists() {
196            if let Ok(info) = BuildInfo::load(&build_info) {
197                metadata.build_info = info;
198            } else {
199                // ignore failure of loading BuildInfo
200                info!("Discarded incompatible .build/info.toml");
201            }
202        }
203
204        debug!(
205            "Loaded metadata ({})",
206            metadata.metadata_path.to_string_lossy()
207        );
208        Ok(metadata)
209    }
210
211    pub fn publish(&mut self) -> Result<(), MetadataError> {
212        let prj_path = self.project_path();
213        let git = Git::open(&prj_path)?;
214        if !git.is_clean()? {
215            return Err(MetadataError::ModifiedProject(prj_path.to_path_buf()));
216        }
217
218        let version = self
219            .project
220            .version
221            .clone()
222            .ok_or(MetadataError::MissingVersion)?;
223
224        for release in &self.pubfile.releases {
225            if release.version == version {
226                return Err(MetadataError::PublishedVersion(version));
227            }
228        }
229
230        let revision = git.get_revision()?;
231
232        info!("Publishing release ({version} @ {revision})");
233
234        let release = Release { version, revision };
235
236        self.pubfile.releases.push(release);
237
238        self.pubfile.save(&self.pubfile_path)?;
239        info!("Writing metadata ({})", self.pubfile_path.to_string_lossy());
240
241        if self.publish.publish_commit {
242            git.add(&self.pubfile_path)?;
243            git.commit(&self.publish.publish_commit_message)?;
244            info!(
245                "Committing metadata ({})",
246                self.pubfile_path.to_string_lossy()
247            );
248        } else {
249            warn!(
250                "Please git add and commit Veryl.pub (set `publish_commit = true` in [publish] to automate this)"
251            );
252        }
253
254        Ok(())
255    }
256
257    pub fn check(&self) -> Result<(), MetadataError> {
258        check_project_name(&self.project.name)?;
259
260        if let Some(ref license) = self.project.license {
261            let _ = Expression::parse(license)?;
262        }
263
264        Ok(())
265    }
266
267    pub fn bump_version(&mut self, kind: BumpKind) -> Result<(), MetadataError> {
268        let prj_path = self.project_path();
269        let git = Git::open(&prj_path)?;
270
271        let current_version = self
272            .project
273            .version
274            .as_ref()
275            .ok_or(MetadataError::MissingVersion)?;
276
277        let mut bumped_version = current_version.clone();
278
279        match kind {
280            BumpKind::Major => {
281                bumped_version.major += 1;
282                bumped_version.minor = 0;
283                bumped_version.patch = 0;
284            }
285            BumpKind::Minor => {
286                bumped_version.minor += 1;
287                bumped_version.patch = 0;
288            }
289            BumpKind::Patch => bumped_version.patch += 1,
290        }
291        info!(
292            "Bumping version ({} -> {})",
293            current_version, bumped_version
294        );
295
296        self.project.version = Some(bumped_version.clone());
297
298        let toml = fs::read_to_string(&self.metadata_path)
299            .map_err(|x| MetadataError::file_io(x, &self.metadata_path))?;
300        let re = Regex::new(r#"version\s+=\s+"([^"]*)""#).unwrap();
301        let caps = re
302            .captures(&toml)
303            .expect("safely unwrap because metadata is valid");
304        let bumped_field = caps[0].replace(&caps[1], &bumped_version.to_string());
305        let bumped_toml = re.replace(&toml, bumped_field);
306        fs::write(&self.metadata_path, bumped_toml.as_bytes())
307            .map_err(|x| MetadataError::file_io(x, &self.metadata_path))?;
308        info!(
309            "Updating version field ({})",
310            self.metadata_path.to_string_lossy()
311        );
312
313        if self.publish.bump_commit {
314            git.add(&self.metadata_path)?;
315            git.commit(&self.publish.bump_commit_message)?;
316            info!(
317                "Committing metadata ({})",
318                self.metadata_path.to_string_lossy()
319            );
320        }
321
322        Ok(())
323    }
324
325    pub fn update_lockfile(&mut self) -> Result<(), MetadataError> {
326        let modified = if self.lockfile_path.exists() {
327            let mut lockfile = Lockfile::load(self)?;
328            let modified = lockfile.update(self, false)?;
329            self.lockfile = lockfile;
330            modified
331        } else {
332            self.lockfile = Lockfile::new(self)?;
333            true
334        };
335        if modified {
336            self.lockfile.save(&self.lockfile_path)?;
337        }
338        Ok(())
339    }
340
341    pub fn save_build_info(&mut self) -> Result<(), MetadataError> {
342        let build_info = self.project_build_info_path();
343        self.build_info.save(&build_info)
344    }
345
346    pub fn add_generated_file(&mut self, path: PathBuf) {
347        self.build_info
348            .generated_files
349            .insert(path, SystemTime::now());
350    }
351
352    pub fn paths<T: AsRef<Path>>(
353        &mut self,
354        files: &[T],
355        symlink: bool,
356        include_dependencies: bool,
357    ) -> Result<Vec<PathSet>, MetadataError> {
358        let sources = if self.build.source.iter().count() > 0 {
359            warn!(
360                "[Veryl.toml] \"source\" field is deprecated. Replace it with \"sources\" field."
361            );
362            vec![self.build.source.clone()]
363        } else {
364            self.build.sources.clone()
365        };
366
367        let base = self.project_path();
368        // Build outputs may be redirected (e.g. `veryl build --out-dir`);
369        // sources are always resolved against the project path.
370        let out_base = self.output_dir();
371        let mut ret = Vec::new();
372
373        // Pre-canonicalize explicit file args once so we can route each to
374        // the source dir it actually belongs to (without re-processing the
375        // same file for every configured source dir).
376        let canonical_files = if files.is_empty() {
377            None
378        } else {
379            let mut v = Vec::new();
380            for file in files {
381                v.push(
382                    fs::canonicalize(file.as_ref())
383                        .map_err(|x| MetadataError::file_io(x, file.as_ref()))?,
384                );
385            }
386            Some(v)
387        };
388        let mut explicit_routed = canonical_files.as_ref().map(|v| vec![false; v.len()]);
389
390        // `examples/` is reserved; dependency source collection
391        // (`Lockfile::paths`) skips it entirely.
392        let examples_base = base.join("examples");
393        if let Some(source) = sources
394            .iter()
395            .find(|x| base.join(x).starts_with(&examples_base))
396        {
397            return Err(MetadataError::ReservedSourceDir(base.join(source)));
398        }
399
400        let mut source_dirs: Vec<(PathBuf, bool)> =
401            sources.iter().map(|x| (base.join(x), false)).collect();
402        if examples_base.exists() {
403            source_dirs.push((examples_base.clone(), true));
404        }
405
406        for (src_base, is_example) in source_dirs {
407            let src_files = if let Some(cf) = canonical_files.as_ref() {
408                // Only keep files that live under this source dir; other
409                // source dirs in `sources` will pick them up. Files under
410                // `examples/` belong to the examples dir only, even when a
411                // source dir contains it.
412                let mut ret = Vec::new();
413                for (i, path) in cf.iter().enumerate() {
414                    if path.starts_with(&src_base)
415                        && (is_example || !path.starts_with(&examples_base))
416                    {
417                        ret.push(path.clone());
418                        if let Some(ref mut flags) = explicit_routed {
419                            flags[i] = true;
420                        }
421                    }
422                }
423                ret
424            } else {
425                let mut files =
426                    veryl_path::gather_files_with_extension(&src_base, "veryl", symlink)?;
427                if !is_example {
428                    files.retain(|x| !x.starts_with(&examples_base));
429                }
430                files
431            };
432
433            for src in src_files {
434                let Ok(src_relative) = src.strip_prefix(&src_base) else {
435                    return Err(MetadataError::InvalidSourceLocation(src));
436                };
437                let dst = match self.build.target {
438                    Target::Source => {
439                        if self.output_dir_override.is_some() {
440                            // Redirected source-target builds keep the
441                            // source-relative layout under the override.
442                            out_base.join(src_relative.with_extension("sv"))
443                        } else {
444                            src.with_extension("sv")
445                        }
446                    }
447                    Target::Directory { ref path } => {
448                        out_base.join(path.join(src_relative.with_extension("sv")))
449                    }
450                    Target::Bundle { .. } => out_base.join(
451                        PathBuf::from("target").join(src.with_extension("sv").file_name().unwrap()),
452                    ),
453                };
454                let map = match &self.build.sourcemap_target {
455                    SourceMapTarget::Directory { path } => {
456                        if let Target::Directory { .. } = self.build.target {
457                            out_base.join(path.join(src_relative.with_extension("sv.map")))
458                        } else {
459                            let dst = dst.strip_prefix(&out_base).unwrap();
460                            out_base.join(path.join(dst.with_extension("sv.map")))
461                        }
462                    }
463                    _ => {
464                        let mut map = dst.clone();
465                        map.set_extension("sv.map");
466                        map
467                    }
468                };
469                ret.push(PathSet {
470                    prj: self.project.name.clone(),
471                    src: src.to_path_buf(),
472                    dst,
473                    map,
474                    example: is_example,
475                });
476            }
477        }
478
479        // Any explicit file that wasn't claimed by a configured source dir
480        // is outside the project — preserve the original error semantics.
481        if let (Some(cf), Some(flags)) = (canonical_files.as_ref(), explicit_routed.as_ref())
482            && let Some(pos) = flags.iter().position(|f| !f)
483        {
484            return Err(MetadataError::InvalidSourceLocation(cf[pos].clone()));
485        }
486
487        let base_dst = self.output_dependencies_path();
488        if !base_dst.exists() {
489            ignore_already_exists(fs::create_dir_all(&base_dst))
490                .map_err(|x| MetadataError::file_io(x, &base_dst))?;
491        }
492
493        if include_dependencies {
494            if !self.build.exclude_std {
495                veryl_std::expand()?;
496                ret.append(&mut veryl_std::paths(&base_dst)?);
497            }
498
499            self.update_lockfile()?;
500
501            let mut deps = self.lockfile.paths(&base_dst)?;
502            ret.append(&mut deps);
503        }
504
505        Ok(ret)
506    }
507
508    pub fn create_default_toml(name: &str) -> Result<String, MetadataError> {
509        check_project_name(name)?;
510
511        Ok(format!(
512            r###"[project]
513name = "{name}"
514version = "0.1.0"
515[build]
516sources = ["src"]
517target = {{type = "directory", path = "target"}}"###
518        ))
519    }
520
521    pub fn create_default(name: &str) -> Result<Metadata, MetadataError> {
522        let metadata: Metadata = toml::from_str(&Self::create_default_toml(name)?)?;
523        Ok(metadata)
524    }
525
526    pub fn create_default_gitignore() -> &'static str {
527        r#"# Build output
528.build/
529/target
530/dependencies
531*.f
532
533# Verilator
534obj_dir/
535"#
536    }
537
538    pub fn project_path(&self) -> PathBuf {
539        self.metadata_path.parent().unwrap().to_path_buf()
540    }
541
542    pub fn output_dir(&self) -> PathBuf {
543        self.output_dir_override
544            .clone()
545            .unwrap_or_else(|| self.project_path())
546    }
547
548    pub fn project_dependencies_path(&self) -> PathBuf {
549        self.project_path().join("dependencies")
550    }
551
552    pub fn output_dependencies_path(&self) -> PathBuf {
553        self.output_dir().join("dependencies")
554    }
555
556    pub fn project_dot_build_path(&self) -> PathBuf {
557        self.project_path().join(".build")
558    }
559
560    pub fn project_build_info_path(&self) -> PathBuf {
561        self.project_dot_build_path().join("info.toml")
562    }
563
564    pub fn filelist_path(&self) -> PathBuf {
565        let filelist_name = match self.build.filelist_type {
566            FilelistType::Absolute => format!("{}.f", self.project.name),
567            FilelistType::Relative => format!("{}.f", self.project.name),
568            FilelistType::Flgen => format!("{}.list.rb", self.project.name),
569        };
570
571        self.output_dir().join(filelist_name)
572    }
573
574    pub fn doc_path(&self) -> PathBuf {
575        self.metadata_path.parent().unwrap().join(&self.doc.path)
576    }
577
578    /// Collects `[[components]]` declared by direct dependencies. Requires a
579    /// loaded lockfile; the dependency checkouts are already present when
580    /// this is called from the build/test flow.
581    pub fn collect_dependency_components(
582        &self,
583    ) -> Result<Vec<crate::lockfile::DependencyComponents>, MetadataError> {
584        self.lockfile.collect_components()
585    }
586
587    /// Collects the interface manifests of this project's and its direct
588    /// dependencies' components, keyed like the `$comp` symbols
589    /// (`<name>` / `<project>::<name>`). The names are the packages'
590    /// `veryl_component_export!` export names, enumerated per
591    /// `[[components]]` entry (see [`Component::collect_manifests`] for
592    /// the source priority). On a name collision the earlier entry wins.
593    pub fn collect_component_manifests(
594        &self,
595    ) -> Vec<(String, crate::component_manifest::ComponentManifest)> {
596        // Enumerates one project's entries with first-declaration-wins
597        // dedup; the same policy must hold wherever exports are collected
598        // (`veryl test` mirrors it when registering libraries).
599        fn collect_project(
600            components: &[crate::Component],
601            root: &Path,
602            target_dir: &Path,
603            project: Option<&str>,
604            ret: &mut Vec<(String, crate::component_manifest::ComponentManifest)>,
605        ) {
606            let mut seen = std::collections::HashSet::new();
607            for def in components {
608                for (name, manifest) in def.collect_manifests(root, target_dir) {
609                    if seen.insert(name.clone()) {
610                        let key = match project {
611                            Some(project) => format!("{project}::{name}"),
612                            None => name,
613                        };
614                        ret.push((key, manifest));
615                    } else {
616                        let scope = project
617                            .map(|p| format!(" of dependency `{p}`"))
618                            .unwrap_or_default();
619                        log::warn!(
620                            "component `{name}` is exported by more than one [[components]] package{scope}; the first declaration wins"
621                        );
622                    }
623                }
624            }
625        }
626
627        let mut ret = vec![];
628        // An in-memory metadata (no backing Veryl.toml) has no project
629        // directory to read manifests from.
630        if self.metadata_path.as_os_str().is_empty() {
631            return ret;
632        }
633        let root = self.project_path();
634        let target_dir = root.join("target/veryl-components");
635        collect_project(&self.components, &root, &target_dir, None, &mut ret);
636        if let Ok(deps) = self.collect_dependency_components() {
637            for dep in &deps {
638                collect_project(
639                    &dep.components,
640                    &dep.root,
641                    &dep.target_dir,
642                    Some(&dep.project),
643                    &mut ret,
644                );
645            }
646        }
647        ret
648    }
649}
650
651impl FromStr for Metadata {
652    type Err = MetadataError;
653
654    fn from_str(s: &str) -> Result<Self, Self::Err> {
655        let metadata: Metadata = toml::from_str(s)?;
656        Ok(metadata)
657    }
658}
659
660#[derive(Clone, Debug, Serialize, Deserialize)]
661#[serde(untagged)]
662#[serde(deny_unknown_fields)]
663pub enum Dependency {
664    Version(VersionReq),
665    Entry(Box<DependencyEntry>),
666}
667
668#[derive(Clone, Debug, Serialize, Deserialize)]
669#[serde(deny_unknown_fields)]
670pub struct DependencyEntry {
671    pub version: Option<VersionReq>,
672    pub git: Option<UrlPath>,
673    pub github: Option<String>,
674    pub project: Option<String>,
675    pub path: Option<PathBuf>,
676    #[serde(default)]
677    pub properties: HashMap<String, ProjectProperty>,
678}