diff --git a/Cargo.toml b/Cargo.toml index 0be778108..294746efd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ edition = "2024" [dependencies] base-db.workspace = true +fusesoc-model.workspace = true hir-def.workspace = true ide.workspace = true preproc-expand.workspace = true @@ -55,6 +56,7 @@ triomphe.workspace = true [workspace.dependencies] base-db = { path = "./crates/base-db/", version = "0.0.0" } +fusesoc-model = { path = "./crates/fusesoc-model", version = "0.0.0" } hir-def = { path = "./crates/hir-def/", version = "0.0.0" } hir-semantics = { path = "./crates/hir-semantics/", version = "0.0.0" } hir-ty = { path = "./crates/hir-ty/", version = "0.0.0" } @@ -108,6 +110,7 @@ toml_edit = "0.22.27" toml_parser = "=1.1.2" tracing = "0.1.37" triomphe = "0.1.9" +saphyr = "0.0.11" winapi = { version = "0.3.9", features = ["processthreadsapi"] } [profile.dev] diff --git a/crates/fusesoc-model/Cargo.toml b/crates/fusesoc-model/Cargo.toml new file mode 100644 index 000000000..85cbacdb8 --- /dev/null +++ b/crates/fusesoc-model/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "fusesoc-model" +version = "0.0.0" +description = "FuseSoC CLI EDAM integration" +edition.workspace = true + +[dependencies] +serde.workspace = true +serde_yaml_ng = "0.10" +thiserror.workspace = true +tracing.workspace = true +tempfile.workspace = true +utils = { workspace = true, features = ["camino_serde1"] } +saphyr.workspace = true diff --git a/crates/fusesoc-model/src/cli.rs b/crates/fusesoc-model/src/cli.rs new file mode 100644 index 000000000..960c4827d --- /dev/null +++ b/crates/fusesoc-model/src/cli.rs @@ -0,0 +1,501 @@ +//! FuseSoC CLI integration. +//! +//! FuseSoC's supported machine-readable project description is the EDAM YAML +//! emitted by `fusesoc run --setup`. Keep the process boundary here so the +//! rest of the project model consumes the same flat representation regardless +//! of whether the project came from a core file or a VLNV. + +use std::{collections::BTreeMap, fs, process::Command}; + +use saphyr::{LoadableYamlNode, MarkedYaml}; +use serde::Deserialize; +use serde_yaml_ng::Value; +use utils::paths::{AbsPath, AbsPathBuf, Utf8PathBuf}; + +use crate::{ResolvedCore, ResolvedFile, ResolvedProject}; + +#[derive(Debug, thiserror::Error)] +pub enum CliError { + #[error("failed to start FuseSoC CLI: {0}")] + Spawn(#[source] std::io::Error), + #[error("FuseSoC CLI failed with {status}\nstdout:\n{stdout}\nstderr:\n{stderr}")] + Failed { status: String, stdout: String, stderr: String }, + #[error("failed to create FuseSoC CLI work directory: {0}")] + WorkDirectory(#[source] std::io::Error), + #[error("failed to inspect FuseSoC CLI work directory: {0}")] + InspectWorkDirectory(#[source] std::io::Error), + #[error("FuseSoC CLI did not produce an EDAM file in {0}")] + MissingEdam(AbsPathBuf), + #[error("FuseSoC CLI produced multiple EDAM files in {0}: {1:?}")] + MultipleEdam(AbsPathBuf, Vec), + #[error("failed to read EDAM file {path}: {source}")] + ReadEdam { path: AbsPathBuf, source: std::io::Error }, + #[error("failed to parse EDAM file {path}: {source}")] + ParseEdam { path: AbsPathBuf, source: serde_yaml_ng::Error }, + #[error("EDAM field `{field}` is missing")] + MissingField { field: &'static str }, + #[error("EDAM field `{field}` has an invalid value: {detail}")] + InvalidField { field: &'static str, detail: String }, + #[error("invalid FuseSoC core name in {path}: {detail}")] + CoreName { path: AbsPathBuf, detail: String }, + #[error("failed to read FuseSoC core {path}: {source}")] + ReadCore { path: AbsPathBuf, source: std::io::Error }, + #[error("failed to parse FuseSoC core in {path}: {detail}")] + ParseCore { path: AbsPathBuf, detail: String }, +} + +#[derive(Debug, Deserialize)] +struct Edam { + #[serde(default)] + files: Vec, + #[serde(default)] + parameters: BTreeMap, + #[serde(default)] + cores: BTreeMap, + toplevel: Option, +} + +#[derive(Debug, Deserialize)] +struct EdamFile { + name: String, + #[serde(default)] + file_type: String, + #[serde(default)] + is_include_file: bool, + #[serde(default)] + include_path: Option, + #[serde(default)] + logical_name: Option, + #[serde(default)] + define: BTreeMap, +} + +#[derive(Debug, Deserialize)] +struct EdamParameter { + #[serde(default)] + paramtype: String, + #[serde(default)] + default: Option, +} + +#[derive(Debug, Deserialize)] +struct EdamCore { + core_file: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CoreTargetInfo { + pub name: String, + pub description: Option, + pub default_tool: Option, + pub flow: Option, + pub has_toplevel: bool, + #[serde(skip_serializing)] + pub source_line: u32, +} + +/// Load a core file through the FuseSoC CLI. +pub fn load_core( + core_path: &AbsPathBuf, + target: &str, + flags: &[String], +) -> Result { + let core_name = read_core_name(core_path)?; + let workspace_root = core_path.parent().ok_or_else(|| CliError::CoreName { + path: core_path.clone(), + detail: "core path has no parent".to_owned(), + })?; + + load_vlnv(workspace_root, &core_name, target, flags) +} + +/// Read only the identity needed to invoke the CLI. FuseSoC remains +/// authoritative for all actual CAPI2 parsing and project expansion. +fn read_core_name(core_path: &AbsPathBuf) -> Result { + let text = fs::read_to_string(core_path.as_path()) + .map_err(|source| CliError::ReadCore { path: core_path.clone(), source })?; + let document = parse_core_document(core_path, &text)?; + let name = + document.data.as_mapping_get("name").and_then(|name| name.data.as_str()).ok_or_else( + || CliError::CoreName { + path: core_path.clone(), + detail: "name is missing or not a string".to_owned(), + }, + )?; + if name.is_empty() { + return Err(CliError::CoreName { + path: core_path.clone(), + detail: "name is empty".to_owned(), + }); + } + Ok(name.to_owned()) +} + +/// Read the target metadata needed by the project-selection UX. +/// +/// This intentionally only reads the target names and display metadata. FuseSoC +/// remains authoritative for dependency resolution and EDAM generation. +pub fn read_core_targets(core_path: &AbsPathBuf) -> Result, CliError> { + let text = fs::read_to_string(core_path.as_path()) + .map_err(|source| CliError::ReadCore { path: core_path.clone(), source })?; + read_core_targets_from_text(core_path, &text) +} + +/// Read target metadata from an already-loaded core buffer. +pub fn read_core_targets_from_text( + core_path: &AbsPathBuf, + text: &str, +) -> Result, CliError> { + let document = parse_core_document(core_path, text)?; + let Some(targets) = document.data.as_mapping_get("targets") else { + return Ok(Vec::new()); + }; + let Some(targets) = targets.data.as_mapping() else { + return Err(CliError::ParseCore { + path: core_path.clone(), + detail: "targets is not a mapping".to_owned(), + }); + }; + + targets + .iter() + .map(|(name, target)| { + let source_line = + u32::try_from(name.span.start.line()).map_err(|_| CliError::ParseCore { + path: core_path.clone(), + detail: "target source line is too large".to_owned(), + })?; + let name = name.data.as_str().ok_or_else(|| CliError::ParseCore { + path: core_path.clone(), + detail: "target name is not a string".to_owned(), + })?; + target.data.as_mapping().ok_or_else(|| CliError::ParseCore { + path: core_path.clone(), + detail: format!("target `{name}` is not a mapping"), + })?; + Ok(CoreTargetInfo { + name: name.to_owned(), + description: yaml_string_field(target, "description", core_path)?, + default_tool: yaml_string_field(target, "default_tool", core_path)?, + flow: yaml_string_field(target, "flow", core_path)?, + has_toplevel: target.data.as_mapping_get("toplevel").is_some(), + source_line, + }) + }) + .collect() +} + +fn yaml_string_field( + node: &MarkedYaml<'_>, + field: &'static str, + core_path: &AbsPathBuf, +) -> Result, CliError> { + node.data + .as_mapping_get(field) + .map(|value| { + value.data.as_str().map(str::to_owned).ok_or_else(|| CliError::ParseCore { + path: core_path.clone(), + detail: format!("field `{field}` is not a string"), + }) + }) + .transpose() +} + +fn parse_core_document<'a>( + core_path: &AbsPathBuf, + text: &'a str, +) -> Result, CliError> { + let body = core_body(core_path, text)?; + let documents = MarkedYaml::load_from_str(body).map_err(|source| CliError::ParseCore { + path: core_path.clone(), + detail: source.to_string(), + })?; + let [document] = documents.as_slice() else { + return Err(CliError::ParseCore { + path: core_path.clone(), + detail: format!("expected one YAML document, got {}", documents.len()), + }); + }; + Ok(document.clone()) +} + +fn core_body<'a>(core_path: &AbsPathBuf, text: &'a str) -> Result<&'a str, CliError> { + let (first, body) = text.split_once('\n').ok_or_else(|| CliError::CoreName { + path: core_path.clone(), + detail: "missing CAPI=2 preamble".to_owned(), + })?; + if first.trim() != "CAPI=2:" { + return Err(CliError::CoreName { + path: core_path.clone(), + detail: format!("expected CAPI=2 preamble, got `{first}`"), + }); + } + Ok(body) +} + +/// Load a VLNV through the FuseSoC CLI. +pub fn load_vlnv( + workspace_root: &AbsPath, + vlnv: &str, + target: &str, + flags: &[String], +) -> Result { + let work_dir = tempfile::tempdir().map_err(CliError::WorkDirectory)?; + let work_root = utils::paths::abs_path_buf_from_path_buf(work_dir.path().to_path_buf()) + .ok_or_else(|| CliError::InvalidField { + field: "work_root", + detail: format!( + "temporary path is not an absolute UTF-8 path: {}", + work_dir.path().display() + ), + })?; + + let mut command = Command::new("fusesoc"); + command + .arg("--monochrome") + .arg("--cores-root") + .arg(workspace_root) + .arg("run") + .arg("--no-export") + .arg("--setup") + .arg("--work-root") + .arg(work_root.as_path()) + .arg("--target") + .arg(target); + for flag in flags { + command.arg(format!("--flag={flag}")); + } + command.arg(vlnv).current_dir(workspace_root); + + tracing::debug!( + workspace_root = %workspace_root, + vlnv, + target, + flags = ?flags, + "running FuseSoC CLI to resolve project" + ); + + let output = command.output().map_err(CliError::Spawn)?; + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + tracing::debug!(stdout = %stdout, stderr = %stderr, "FuseSoC CLI completed"); + if !output.status.success() { + return Err(CliError::Failed { + status: output + .status + .code() + .map_or_else(|| "terminated by signal".to_owned(), |code| code.to_string()), + stdout, + stderr, + }); + } + + let mut edam_paths = Vec::new(); + for entry in fs::read_dir(work_root.as_path()).map_err(CliError::InspectWorkDirectory)? { + let path = entry.map_err(CliError::InspectWorkDirectory)?.path(); + if !path.extension().is_some_and(|extension| extension == "yml") + || !path.file_name().is_some_and(|name| name.to_string_lossy().ends_with(".eda.yml")) + { + continue; + } + let path = utils::paths::abs_path_buf_from_path_buf(path).ok_or_else(|| { + CliError::InvalidField { + field: "EDAM path", + detail: "EDAM path is not an absolute UTF-8 path".to_owned(), + } + })?; + edam_paths.push(path); + } + + let edam_path = match edam_paths.as_slice() { + [] => return Err(CliError::MissingEdam(work_root)), + [path] => path.clone(), + paths => return Err(CliError::MultipleEdam(work_root, paths.to_vec())), + }; + + let edam_text = fs::read_to_string(edam_path.as_path()) + .map_err(|source| CliError::ReadEdam { path: edam_path.clone(), source })?; + let edam: Edam = serde_yaml_ng::from_str(&edam_text) + .map_err(|source| CliError::ParseEdam { path: edam_path.clone(), source })?; + + project_from_edam(&edam_path, edam) +} + +fn project_from_edam(edam_path: &AbsPathBuf, edam: Edam) -> Result { + let work_root = edam_path.parent().ok_or(CliError::MissingField { field: "EDAM parent" })?; + + let mut files = Vec::new(); + let mut include_dirs = Vec::new(); + let mut defines = Vec::new(); + for file in edam.files { + if !is_verilog_source(&file.file_type) { + continue; + } + + let path = resolve_path(work_root, &file.name, "files[].name")?; + let include_path = file + .include_path + .as_deref() + .map(|path| resolve_path(work_root, path, "files[].include_path")) + .transpose()?; + let include_path = include_path.or_else(|| { + file.is_include_file + .then(|| path.as_path().parent().map(|parent| parent.to_path_buf())) + .flatten() + }); + if let Some(include_path) = &include_path { + include_dirs.push(include_path.clone()); + } + + let file_defines = file + .define + .into_iter() + .map(|(name, value)| value_to_define("files[].define", name, value)) + .collect::, _>>()?; + defines.extend(file_defines.iter().cloned()); + files.push(ResolvedFile { + path, + file_type: file.file_type, + is_include_file: file.is_include_file, + include_path, + defines: file_defines, + logical_name: file.logical_name, + }); + } + + for (name, parameter) in edam.parameters { + if parameter.paramtype != "vlogdefine" { + continue; + } + let Some(value) = parameter.default else { + continue; + }; + defines.push(value_to_define("parameters", name, value)?); + } + + include_dirs.sort(); + include_dirs.dedup(); + let top_modules = edam + .toplevel + .ok_or(CliError::MissingField { field: "toplevel" })? + .split_whitespace() + .map(str::to_owned) + .collect(); + + let cores = edam + .cores + .into_iter() + .map(|(name, core)| { + let core_file = resolve_path(work_root, &core.core_file, "cores[].core_file")?; + let core_root = core_file.parent().ok_or(CliError::InvalidField { + field: "cores[].core_file", + detail: format!("core file has no parent: {core_file}"), + })?; + Ok(ResolvedCore { name, core_root: core_root.to_path_buf(), core_file }) + }) + .collect::, CliError>>()?; + + Ok(ResolvedProject { files, include_dirs, defines, top_modules, cores }) +} + +fn resolve_path(base: &AbsPath, path: &str, field: &'static str) -> Result { + let path = Utf8PathBuf::from(path); + if path.is_absolute() { + AbsPathBuf::try_from(path).map_err(|path| CliError::InvalidField { + field, + detail: format!("path is not a valid absolute path: {path}"), + }) + } else { + Ok(base.join(path).normalize()) + } +} + +fn value_to_define( + field: &'static str, + name: String, + value: Value, +) -> Result<(String, String), CliError> { + let value = match value { + Value::String(value) => value, + Value::Bool(value) => value.to_string(), + Value::Number(value) => value.to_string(), + other => { + return Err(CliError::InvalidField { + field, + detail: format!("define `{name}` is not a scalar: {other:?}"), + }); + } + }; + Ok((name, value)) +} + +fn is_verilog_source(file_type: &str) -> bool { + file_type.to_ascii_lowercase().contains("verilog") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_edam_to_project() { + let root = tempfile::tempdir().unwrap(); + let root = utils::paths::abs_path_buf_from_path_buf(root.path().to_path_buf()).unwrap(); + let edam_path = root.join("project.eda.yml"); + fs::write( + edam_path.as_path(), + r#" +toplevel: top +parameters: + WIDTH: + paramtype: vlogdefine + default: 32 +files: + - name: rtl/top.sv + file_type: systemVerilogSource + - name: include/config.vh + file_type: verilogSource + is_include_file: true + - name: constraints.xdc + file_type: XDC +cores: + v:l:top:1.0: + core_file: top.core +"#, + ) + .unwrap(); + + let edam: Edam = + serde_yaml_ng::from_str(&fs::read_to_string(edam_path.as_path()).unwrap()).unwrap(); + let project = project_from_edam(&edam_path, edam).unwrap(); + assert_eq!(project.top_modules, ["top"]); + assert_eq!(project.files.len(), 2); + assert_eq!(project.include_dirs, [root.join("include")]); + assert!(project.defines.contains(&("WIDTH".to_owned(), "32".to_owned()))); + assert_eq!(project.cores[0].core_file, root.join("top.core")); + } + + #[test] + fn reads_core_target_metadata_without_resolving_dependencies() { + let root = tempfile::tempdir().unwrap(); + let root = utils::paths::abs_path_buf_from_path_buf(root.path().to_path_buf()).unwrap(); + let core_path = root.join("top.core"); + fs::write( + core_path.as_path(), + "CAPI=2:\nname: v:l:top:1.0\ntargets:\n default:\n filesets: [rtl]\n lint:\n description: Run static checks\n default_tool: verilator\n toplevel: top\n", + ) + .unwrap(); + + let targets = read_core_targets(&core_path).unwrap(); + + assert_eq!(targets.len(), 2); + assert_eq!(targets[0].name, "default"); + assert!(!targets[0].has_toplevel); + assert_eq!(targets[0].source_line, 3); + assert_eq!(targets[1].name, "lint"); + assert_eq!(targets[1].default_tool.as_deref(), Some("verilator")); + assert!(targets[1].has_toplevel); + assert_eq!(targets[1].source_line, 5); + } +} diff --git a/crates/fusesoc-model/src/lib.rs b/crates/fusesoc-model/src/lib.rs new file mode 100644 index 000000000..bbc3ccbcb --- /dev/null +++ b/crates/fusesoc-model/src/lib.rs @@ -0,0 +1,38 @@ +//! FuseSoC CLI integration for Vide project loading. +//! +//! FuseSoC owns CAPI2 parsing, dependency resolution, target expansion, +//! providers, and generators. This crate only models the EDAM metadata that +//! FuseSoC emits for an IDE to consume. + +use utils::paths::AbsPathBuf; + +pub mod cli; + +/// A fully resolved FuseSoC project as represented by EDAM. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedProject { + pub files: Vec, + pub include_dirs: Vec, + pub defines: Vec<(String, String)>, + pub top_modules: Vec, + pub cores: Vec, +} + +/// A source or include file from EDAM. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedFile { + pub path: AbsPathBuf, + pub file_type: String, + pub is_include_file: bool, + pub include_path: Option, + pub defines: Vec<(String, String)>, + pub logical_name: Option, +} + +/// A core that contributed to the EDAM project. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedCore { + pub name: String, + pub core_root: AbsPathBuf, + pub core_file: AbsPathBuf, +} diff --git a/crates/ide/src/manifest.rs b/crates/ide/src/manifest.rs index 202a882e5..dcf04dded 100644 --- a/crates/ide/src/manifest.rs +++ b/crates/ide/src/manifest.rs @@ -115,46 +115,88 @@ fn parse_document(text: &str) -> Result, ManifestParseError> range: error.span().and_then(text_range), message: error.to_string(), })?; - let key_ranges = parser_key_ranges(text)?; + let root = document.as_table(); let mut entries = Vec::new(); - let mut key_ranges = key_ranges.into_iter(); for (key, item) in document.iter() { - if item.as_value().is_none() { - return Err(ManifestParseError { - range: item.span().and_then(text_range), - message: format!("nested TOML table `{key}` is not supported in vide.toml"), - }); + // `toml_edit` keeps exact key spans for parsed documents, one per + // root entry — no positional pairing with a separate parser pass. + let key_range = root.key(key).and_then(|key| key.span()).and_then(text_range); + match item { + Item::Value(value) => { + let Some(value_range) = value.span().and_then(text_range) else { + tracing::error!(key = %key, "toml_edit returned an entry without a value span"); + return Err(ManifestParseError { + range: None, + message: format!("TOML entry `{key}` has no value span"), + }); + }; + let Some(key_range) = key_range else { + tracing::error!(key = %key, "toml_edit returned an entry without a key span"); + return Err(ManifestParseError { + range: Some(value_range), + message: format!("TOML entry `{key}` has no key span"), + }); + }; + entries.push(ManifestEntry { + key: key.to_owned(), + key_range, + value_range, + full_range: TextRange::new(key_range.start(), value_range.end()), + values: item_values(text, key, value), + }); + } + // Top-level tables (`[fusesoc]`) and arrays of tables are valid + // vide.toml configuration. Index the header key; the body is + // owned by the project loader and carries no manifest values. + Item::Table(table) => { + let Some(value_range) = table.span().and_then(text_range) else { + tracing::error!(key = %key, "toml_edit returned a table without a span"); + return Err(ManifestParseError { + range: None, + message: format!("TOML table `{key}` has no source span"), + }); + }; + let Some(key_range) = key_range else { + tracing::error!(key = %key, "toml_edit returned a table without a key span"); + return Err(ManifestParseError { + range: Some(value_range), + message: format!("TOML table `{key}` has no key span"), + }); + }; + entries.push(ManifestEntry { + key: key.to_owned(), + key_range, + value_range, + full_range: value_range, + values: Vec::new(), + }); + } + Item::ArrayOfTables(tables) => { + let Some(value_range) = tables.span().and_then(text_range) else { + tracing::error!(key = %key, "toml_edit returned array of tables without a span"); + return Err(ManifestParseError { + range: None, + message: format!("TOML array of tables `{key}` has no source span"), + }); + }; + let Some(key_range) = key_range else { + tracing::error!(key = %key, "toml_edit returned array of tables without a key span"); + return Err(ManifestParseError { + range: Some(value_range), + message: format!("TOML array of tables `{key}` has no key span"), + }); + }; + entries.push(ManifestEntry { + key: key.to_owned(), + key_range, + value_range, + full_range: value_range, + values: Vec::new(), + }); + } + Item::None => {} } - let Some(value_range) = item.span().and_then(text_range) else { - tracing::error!(key = %key, "toml_edit returned an entry without a source span"); - return Err(ManifestParseError { - range: None, - message: format!("TOML entry `{key}` has no source span"), - }); - }; - let Some(key_range) = key_ranges.next() else { - return Err(ManifestParseError { - range: Some(value_range), - message: format!("TOML parser returned no key span for `{key}`"), - }); - }; - let full_range = TextRange::new(key_range.start(), value_range.end()); - let values = item_values(text, key, item); - entries.push(ManifestEntry { - key: key.to_owned(), - key_range, - value_range, - full_range, - values, - }); - } - - if key_ranges.next().is_some() { - return Err(ManifestParseError { - range: None, - message: "TOML parser returned more key spans than toml_edit".to_owned(), - }); } entries.sort_by_key(|entry| entry.full_range.start()); Ok(entries) @@ -162,7 +204,6 @@ fn parse_document(text: &str) -> Result, ManifestParseError> #[derive(Debug, Default)] struct ManifestParserSyntax { - key_ranges: Vec, incomplete_key_range: Option, } @@ -191,6 +232,8 @@ fn parser_syntax(text: &str) -> Result<(ManifestParserSyntax, bool), ManifestPar message: "TOML parser emitted an unmatched container close".to_owned(), })? } + // Track only top-level keys: table headers (`[fusesoc]`) emit + // their key at depth 1 and are not incomplete-key candidates. EventKind::SimpleKey if container_depth == 0 => { let span = text_range_from_span(event.span())?; pending_key = Some(match pending_key { @@ -199,13 +242,14 @@ fn parser_syntax(text: &str) -> Result<(ManifestParserSyntax, bool), ManifestPar }); } EventKind::KeyValSep if container_depth == 0 => { - let range = pending_key.take().ok_or(ManifestParseError { - range: Some(text_range_from_span(event.span())?), - message: "TOML parser emitted a key/value separator without a key".to_owned(), - })?; - syntax.key_ranges.push(range); + pending_key.take(); + } + EventKind::Newline if container_depth == 0 && pending_key.is_some() => { + // A key still pending at end of line has no `=` — it is an + // incomplete key. The key/value separator arrives before the + // newline, so completed keys are already cleared here. + syntax.incomplete_key_range = pending_key.take(); } - EventKind::Newline => syntax.incomplete_key_range = pending_key.take(), _ => {} } } @@ -213,17 +257,6 @@ fn parser_syntax(text: &str) -> Result<(ManifestParserSyntax, bool), ManifestPar Ok((syntax, !errors.is_empty())) } -fn parser_key_ranges(text: &str) -> Result, ManifestParseError> { - let (syntax, has_errors) = parser_syntax(text)?; - if has_errors { - return Err(ManifestParseError { - range: None, - message: "TOML parser rejected the document while producing source spans".to_owned(), - }); - } - Ok(syntax.key_ranges) -} - fn text_range_from_span(span: Span) -> Result { text_range(span.start()..span.end()).ok_or(ManifestParseError { range: None, @@ -251,17 +284,12 @@ fn entries_for(db: &dyn SourceDb, file_id: FileId) -> Option> Some(index_for(db, file_id)?.entries.clone()) } -fn item_values(text: &str, key: &str, item: &Item) -> Vec { - if let Some(array) = item.as_array() { +fn item_values(text: &str, key: &str, value: &Value) -> Vec { + if let Some(array) = value.as_array() { return array.iter().filter_map(|value| manifest_value_value(text, key, value)).collect(); } - manifest_value(text, key, item).into_iter().collect() -} - -fn manifest_value(text: &str, key: &str, item: &Item) -> Option { - let value = item.as_value()?; - manifest_value_value(text, key, value) + manifest_value_value(text, key, value).into_iter().collect() } fn manifest_value_value(text: &str, key: &str, value: &Value) -> Option { @@ -949,4 +977,48 @@ mod tests { "# trailing" ); } + + #[test] + fn parse_keeps_top_level_table_entry() { + let text = "[fusesoc]\ntarget = \"agilex5\"\n"; + let entries = parse_document(text).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].key, "fusesoc"); + assert_eq!(entries[0].key_range, TextRange::new(TextSize::from(1), TextSize::from(8))); + assert_eq!(entries[0].full_range, TextRange::new(TextSize::from(0), TextSize::from(28))); + assert!(entries[0].values.is_empty()); + } + + #[test] + fn parse_mixed_flat_and_table_entries() { + let text = "top_modules = [\"top\"]\n[fusesoc]\ntarget = \"agilex5\"\n"; + let entries = parse_document(text).unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].key, "top_modules"); + assert_eq!(entries[0].values.len(), 1); + assert_eq!(entries[0].values[0].text, "top"); + assert_eq!(entries[1].key, "fusesoc"); + assert!(entries[1].values.is_empty()); + } + + #[test] + fn table_body_keys_do_not_become_root_entries() { + let text = "[fusesoc]\ntarget = \"agilex5\"\nsources = [\"rtl/**\"]\n"; + let entries = parse_document(text).unwrap(); + // Everything after `[fusesoc]` belongs to that table; `target` and + // `sources` are not root entries. + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].key, "fusesoc"); + assert!(entries[0].values.is_empty()); + } + + #[test] + fn parse_keeps_array_of_tables_entry() { + let text = "[[items]]\nname = \"x\"\n"; + let entries = parse_document(text).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].key, "items"); + assert_eq!(entries[0].key_range, TextRange::new(TextSize::from(2), TextSize::from(7))); + assert!(entries[0].values.is_empty()); + } } diff --git a/crates/project-model/Cargo.toml b/crates/project-model/Cargo.toml index a31911926..f6e5f9ddf 100644 --- a/crates/project-model/Cargo.toml +++ b/crates/project-model/Cargo.toml @@ -11,6 +11,7 @@ manifest-schema = ["dep:schemars", "dep:serde_json"] [dependencies] vfs.workspace = true anyhow.workspace = true +fusesoc-model = { path = "../fusesoc-model" } const_format.workspace = true itertools.workspace = true regex.workspace = true @@ -19,10 +20,12 @@ serde.workspace = true serde_json = { workspace = true, optional = true } smol_str.workspace = true toml = "0.8.8" +toml_edit.workspace = true triomphe.workspace = true utils = { workspace = true, features = ["camino_serde1"] } workspace-model.workspace = true schemars = { version = "1.2.1", features = ["preserve_order"], optional = true } +tracing.workspace = true [dev-dependencies] utils = { workspace = true, features = ["camino_serde1", "test-support"] } diff --git a/crates/project-model/src/lib.rs b/crates/project-model/src/lib.rs index 31129a7f1..594f1b9ae 100644 --- a/crates/project-model/src/lib.rs +++ b/crates/project-model/src/lib.rs @@ -7,6 +7,7 @@ use std::collections::VecDeque; use anyhow::{Context, bail}; use itertools::Itertools; use rustc_hash::{FxHashMap, FxHashSet}; +pub use toml_workspace::TomlWorkspace; #[cfg(feature = "manifest-schema")] pub use toml_workspace::{ TOML_MANIFEST_SCHEMA_PATH, TOML_MANIFEST_SCHEMA_URL, TOML_MANIFEST_SCHEMA_VERSION, @@ -23,9 +24,7 @@ use workspace_model::{ source_root::{SourceRootConfig, SourceRootId, SourceRootRole}, }; -use crate::{ - macro_def::MacroDef, project_manifest::ProjectManifest, toml_workspace::TomlWorkspace, -}; +use crate::{macro_def::MacroDef, project_manifest::ProjectManifest}; const DEFAULT_INDEX_SOURCE_PATTERNS: &[&str] = &["**"]; @@ -172,8 +171,18 @@ impl Workspace { let toml_workspace = TomlWorkspace::load_from_file(toml) .with_context(|| "failed to load workspace in {manifest:?}")?; - Self::from_toml(toml_workspace, is_lib) + // Check if the vide.toml has a [fusesoc] section — if so, + // delegate to FuseSoC loading with the specified core/target. + if let Some(fusesoc_cfg) = &toml_workspace.fusesoc { + Self::from_fusesoc_config(&toml_workspace.workspace_root, fusesoc_cfg, is_lib) + } else { + Self::from_toml(toml_workspace, is_lib) + } + } + ProjectManifest::FuseSocCore(core_path) => { + Self::from_fusesoc_core(core_path, None, None, is_lib) } + ProjectManifest::FuseSocCoreDir(dir) => Self::from_fusesoc_core_dir(dir, is_lib), ProjectManifest::UnconfiguredRoot(path) => { Ok(Self::from_unconfigured_root(path, is_lib)) } @@ -190,6 +199,7 @@ impl Workspace { include_dirs, libraries, exclude_patterns, + fusesoc: _, } = toml; let kind = WorkspaceKind::from_is_lib(is_lib); @@ -241,6 +251,142 @@ impl Workspace { Ok(Self { workspace_root, library_paths, kind, roots, semantic_profile }) } + fn from_fusesoc_core( + core_path: &AbsPathBuf, + target: Option<&str>, + flags: Option<&[String]>, + is_lib: bool, + ) -> anyhow::Result { + let target = target.context(format!( + "FuseSoC target must be explicitly selected for root core {core_path}" + ))?; + let workspace_root = core_path + .parent() + .map(|p| p.to_path_buf()) + .context("FuseSoC .core path has no parent")?; + let resolved = fusesoc_model::cli::load_core(core_path, target, flags.unwrap_or(&[])) + .with_context(|| format!("failed to load FuseSoC core through the CLI: {core_path}"))?; + + Self::from_fusesoc_resolved(&workspace_root, core_path, &resolved, is_lib) + } + + /// Load a FuseSoC project from a `[fusesoc]` section in vide.toml. + fn from_fusesoc_config( + workspace_root: &AbsPathBuf, + cfg: &crate::toml_workspace::FuseSocTomlConfig, + is_lib: bool, + ) -> anyhow::Result { + let target = cfg.target.as_deref().context(format!( + "FuseSoC target must be explicitly selected in {}/vide.toml", + workspace_root + ))?; + // The `core` field can be a file name or a VLNV. Try file name first. + let core_path = workspace_root.join(&cfg.core); + if std::fs::metadata(core_path.as_path()).is_ok() { + return Self::from_fusesoc_core(&core_path, Some(target), Some(&cfg.flags), is_lib); + } + + // Not a file — treat as a VLNV and let FuseSoC resolve libraries and + // dependencies through its own CLI. + let resolved = fusesoc_model::cli::load_vlnv(workspace_root, &cfg.core, target, &cfg.flags) + .with_context(|| { + format!("failed to load FuseSoC VLNV `{}` through the CLI", cfg.core) + })?; + let core_path = resolved + .cores + .first() + .map(|core| core.core_file.clone()) + .context("FuseSoC CLI EDAM did not identify the root core file")?; + + Self::from_fusesoc_resolved(workspace_root, &core_path, &resolved, is_lib) + } + + /// Refuse to guess the root when a directory contains multiple cores. + /// The user must select it in `[fusesoc] core` in `vide.toml`. + fn from_fusesoc_core_dir(dir: &AbsPathBuf, is_lib: bool) -> anyhow::Result { + let _ = is_lib; + anyhow::bail!( + "multiple FuseSoC .core files found in {dir}; select the root core explicitly with [fusesoc]\ncore = \"path/to/top.core\"\n in vide.toml" + ) + } + + /// Build a Workspace from a resolved FuseSoC project. + fn from_fusesoc_resolved( + workspace_root: &AbsPathBuf, + core_path: &AbsPathBuf, + resolved: &fusesoc_model::ResolvedProject, + is_lib: bool, + ) -> anyhow::Result { + use utils::line_index::{TextRange, TextSize}; + + use crate::macro_def::{MacroAtom, MacroDef, MacroDefSource}; + + let kind = WorkspaceKind::from_is_lib(is_lib); + + let source_files: Vec = + resolved.files.iter().filter(|f| !f.is_include_file).map(|f| f.path.clone()).collect(); + + let include_files: Vec = + resolved.files.iter().filter(|f| f.is_include_file).map(|f| f.path.clone()).collect(); + + let include_dirs = resolved.include_dirs.clone(); + + let all_files: Vec = + source_files.iter().chain(include_files.iter()).cloned().collect(); + let source = PathMatcher::all_under_roots(all_files.clone()); + + let predefine_strings: Vec = resolved + .defines + .iter() + .map(|(k, v)| if v.is_empty() { k.clone() } else { format!("{k}={v}") }) + .collect(); + + let mut macros: FxHashSet = FxHashSet::default(); + let mut sources: Vec = Vec::new(); + let zero_range = TextRange::new(TextSize::from(0), TextSize::from(0)); + for s in &predefine_strings { + let atom = if let Some((key, value)) = s.split_once('=') { + MacroAtom::KeyValue { key: key.into(), value: value.into() } + } else { + MacroAtom::Flag(s.into()) + }; + macros.insert(atom.clone()); + sources.push(MacroDefSource { atom, range: zero_range }); + } + let macro_defs = MacroDef { macros, sources }; + + let root_parts = WorkspaceRootParts { + source: source.clone(), + source_directories: source, + source_files: all_files, + extra_files: vec![core_path.clone()], + include_dirs: include_dirs.clone(), + exclude_prefixes: Vec::new(), + exclude_globs: None, + }; + + let roots = + workspace_roots(kind, &ManifestSourcePolicy::Explicit(vec![]), true, root_parts); + + let semantic_profile = + roots.iter().any(WorkspaceRoot::contributes_semantic_profile).then(|| { + semantic_profile( + resolved.top_modules.clone(), + macro_defs, + include_dirs, + Some(core_path.clone()), + ) + }); + + Ok(Self { + workspace_root: workspace_root.clone(), + library_paths: Vec::new(), + kind, + roots, + semantic_profile, + }) + } + fn from_unconfigured_root(path: &AbsPathBuf, is_lib: bool) -> Self { let kind = WorkspaceKind::from_is_lib(is_lib); let source_roots = vec![path.clone()]; @@ -623,7 +769,10 @@ struct ProjectManifestIdentitySet { impl ProjectManifestIdentitySet { fn insert(&mut self, manifest: &ProjectManifest) -> bool { let path = match manifest { - ProjectManifest::Toml(path) | ProjectManifest::UnconfiguredRoot(path) => path, + ProjectManifest::Toml(path) + | ProjectManifest::FuseSocCore(path) + | ProjectManifest::FuseSocCoreDir(path) + | ProjectManifest::UnconfiguredRoot(path) => path, }; self.paths.insert_path(path.as_path()) } @@ -1601,4 +1750,99 @@ libraries = ["../pkg"] let app_profile = project_config.profile(app_profile_id).unwrap(); assert_eq!(app_profile.source_roots, vec![SourceRootId(0), SourceRootId(1)]); } + + #[test] + fn fusesoc_config_loads_core_with_explicit_target() { + let root = TestDir::new("project-model-fusesoc-config"); + fs::create_dir_all(root.join("rtl")).unwrap(); + fs::write( + root.join("top.core"), + "CAPI=2:\nname: v:l:top:1.0\n\nfilesets:\n rtl:\n files: [rtl/top.sv : {file_type: systemVerilogSource}]\n\ntargets:\n fpga:\n default_tool: icarus\n filesets: [rtl]\n toplevel: top\n", + ) + .unwrap(); + fs::write(root.join("rtl/top.sv"), "module top; endmodule\n").unwrap(); + fs::write( + root.join(project_manifest::MANIFEST_FILE_NAME), + "[fusesoc]\ncore = \"top.core\"\ntarget = \"fpga\"\n", + ) + .unwrap(); + + let manifest = ProjectManifest::from_path(&root.path().to_path_buf()).unwrap(); + let (model, errors) = ProjectModel::load(vec![manifest]); + + assert!(errors.is_empty(), "{errors:#?}"); + assert_eq!(model.workspaces.len(), 1); + let workspace = &model.workspaces[0]; + assert_eq!(workspace.root(), &root.path().to_path_buf()); + assert!(workspace.roots().iter().any(|root| { + root.source_files.iter().any(|file| file.as_path().to_string().ends_with("rtl/top.sv")) + })); + assert_eq!( + workspace.semantic_profile().map(|profile| profile.top_modules.clone()), + Some(vec!["top".to_owned()]) + ); + } + + #[test] + fn fusesoc_config_missing_core_is_rejected() { + let root = TestDir::new("project-model-fusesoc-missing-core"); + fs::write( + root.join(project_manifest::MANIFEST_FILE_NAME), + "[fusesoc]\ntarget = \"fpga\"\n", + ) + .unwrap(); + + let manifest = ProjectManifest::from_path(&root.path().to_path_buf()).unwrap(); + let (model, errors) = ProjectModel::load(vec![manifest]); + + assert!(model.workspaces.is_empty()); + assert_eq!(errors.len(), 1); + assert!( + errors[0].to_string().contains("fusesoc"), + "expected a fusesoc-related error, got: {errors:?}" + ); + } + + #[test] + fn fusesoc_config_missing_target_is_rejected() { + let root = TestDir::new("project-model-fusesoc-missing-target"); + fs::write( + root.join("top.core"), + "CAPI=2:\nname: v:l:top:1.0\ntargets:\n lint:\n filesets: []\n", + ) + .unwrap(); + fs::write( + root.join(project_manifest::MANIFEST_FILE_NAME), + "[fusesoc]\ncore = \"top.core\"\n", + ) + .unwrap(); + + let manifest = ProjectManifest::from_path(&root.path().to_path_buf()).unwrap(); + let (model, errors) = ProjectModel::load(vec![manifest]); + + assert!(model.workspaces.is_empty()); + assert_eq!(errors.len(), 1); + assert!(format!("{:#}", errors[0]).contains("target must be explicitly selected")); + } + + #[test] + fn fusesoc_multiple_cores_require_explicit_root() { + let root = TestDir::new("project-model-fusesoc-multiple-cores"); + for name in ["a", "b"] { + fs::write( + root.join(format!("{name}.core")), + format!("CAPI=2:\nname: v:l:{name}:1.0\n"), + ) + .unwrap(); + } + + let manifest = ProjectManifest::from_path(&root.path().to_path_buf()).unwrap(); + let (model, errors) = ProjectModel::load(vec![manifest]); + + assert!(model.workspaces.is_empty()); + assert_eq!(errors.len(), 1); + let error = format!("{:#}", errors[0]); + assert!(error.contains("select the root core explicitly"), "unexpected error: {error}"); + assert!(error.contains("[fusesoc]")); + } } diff --git a/crates/project-model/src/project_manifest.rs b/crates/project-model/src/project_manifest.rs index 3aa2d0370..a22f899e9 100644 --- a/crates/project-model/src/project_manifest.rs +++ b/crates/project-model/src/project_manifest.rs @@ -2,10 +2,12 @@ use std::{collections::BTreeSet, fs, io::ErrorKind}; use anyhow::{Context, bail}; use const_format::formatcp; +use toml_edit::{DocumentMut, Item, Table, value}; use utils::paths::AbsPathBuf; pub const MANIFEST_FILE_NAME: &str = formatcp!("vide.toml"); pub const MANIFEST_FILE_NAMES: [&str; 1] = [MANIFEST_FILE_NAME]; +pub const FUSESOC_CORE_EXTENSIONS: [&str; 1] = ["core"]; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)] pub enum ProjectManifestFileName { @@ -32,6 +34,11 @@ impl ProjectManifestFileName { #[derive(Debug, Clone, PartialEq, Eq, Hash, Ord, PartialOrd)] pub enum ProjectManifest { Toml(AbsPathBuf), + /// A FuseSoC CAPI2 `.core` file explicitly selected. + FuseSocCore(AbsPathBuf), + /// A directory containing multiple FuseSoC `.core` files. The client + /// should ask the user to select one before reloading the project. + FuseSocCoreDir(AbsPathBuf), UnconfiguredRoot(AbsPathBuf), } @@ -39,6 +46,69 @@ pub fn is_manifest_file_name(file_name: &str) -> bool { ProjectManifestFileName::from_file_name(file_name).is_some() } +/// Return the candidate root cores directly under a workspace directory. +pub fn fusesoc_core_candidates(dir: &AbsPathBuf) -> Vec { + find_core_files(dir) +} + +/// Persist a user-selected FuseSoC root core in the workspace manifest. +/// +/// The selected file must be one of the direct `.core` candidates discovered +/// for the workspace. Existing TOML is edited structurally so comments and +/// unrelated project settings remain intact. +pub fn persist_fusesoc_core_selection( + workspace_root: &AbsPathBuf, + core_path: &AbsPathBuf, +) -> anyhow::Result { + persist_fusesoc_selection(workspace_root, core_path, None) +} + +/// Persist a user-selected FuseSoC root core and optional target. +/// +/// A core-only selection is an intentionally incomplete intermediate state; +/// loading requires a subsequent explicit target selection. +pub fn persist_fusesoc_selection( + workspace_root: &AbsPathBuf, + core_path: &AbsPathBuf, + target: Option<&str>, +) -> anyhow::Result { + anyhow::ensure!( + fusesoc_core_candidates(workspace_root).iter().any(|candidate| candidate == core_path), + "selected FuseSoC core is not a direct .core candidate in {workspace_root}: {core_path}" + ); + if let Some(target) = target { + anyhow::ensure!(!target.is_empty(), "selected FuseSoC target must not be empty"); + } + + let relative_core_path = core_path + .as_path() + .strip_prefix(workspace_root.as_path()) + .with_context(|| format!("FuseSoC core is outside workspace root: {core_path}"))? + .as_str() + .to_owned(); + let manifest_path = workspace_root.join(MANIFEST_FILE_NAME); + let mut document = match fs::read_to_string(&manifest_path) { + Ok(text) => text + .parse::() + .with_context(|| format!("failed to parse {manifest_path}"))?, + Err(error) if error.kind() == ErrorKind::NotFound => DocumentMut::new(), + Err(error) => { + return Err(error).with_context(|| format!("failed to read {manifest_path}")); + } + }; + + let fusesoc = document.entry("fusesoc").or_insert(Item::Table(Table::new())); + let fusesoc = fusesoc.as_table_mut().context("vide.toml [fusesoc] must be a standard table")?; + fusesoc["core"] = value(relative_core_path); + if let Some(target) = target { + fusesoc["target"] = value(target); + } + + fs::write(&manifest_path, document.to_string()) + .with_context(|| format!("failed to write {manifest_path}"))?; + Ok(manifest_path) +} + impl ProjectManifest { pub fn from_paths(paths: &[AbsPathBuf]) -> (Vec, Vec) { let mut manifests = BTreeSet::new(); @@ -60,6 +130,9 @@ impl ProjectManifest { if is_manifest_file_name(path.file_name().unwrap_or_default()) { return Self::from_toml(path); } + if path.extension().is_some_and(|ext| ext == "core") { + return Self::from_fusesoc_core(path); + } let metadata = fs::metadata(path).with_context(|| format!("project path does not exist: {path}"))?; @@ -79,6 +152,18 @@ impl ProjectManifest { } } + // No vide.toml — look for .core files in the workspace root. + let core_files = find_core_files(path); + match core_files.len() { + 0 => {} + 1 => return Self::from_fusesoc_core(&core_files[0]), + _ => { + // Multiple cores require an explicit root selection in + // vide.toml; preserve the directory for an actionable error. + return Ok(Self::FuseSocCoreDir(path.clone())); + } + } + Ok(Self::UnconfiguredRoot(path.clone())) } @@ -87,7 +172,9 @@ impl ProjectManifest { ProjectManifest::Toml(path) => { path.file_name().and_then(ProjectManifestFileName::from_file_name) } - ProjectManifest::UnconfiguredRoot(_) => None, + ProjectManifest::FuseSocCore(_) + | ProjectManifest::FuseSocCoreDir(_) + | ProjectManifest::UnconfiguredRoot(_) => None, } } @@ -108,6 +195,39 @@ impl ProjectManifest { Ok(ProjectManifest::Toml(path.clone())) } + + fn from_fusesoc_core(path: &AbsPathBuf) -> anyhow::Result { + if path.parent().is_none() { + bail!("bad .core path: {path}"); + } + + let metadata = fs::metadata(path) + .with_context(|| format!("project .core path does not exist: {path}"))?; + if !metadata.is_file() { + bail!("project .core path is not a file: {path}"); + } + + Ok(ProjectManifest::FuseSocCore(path.clone())) + } +} + +/// Find all `.core` files directly in `dir` (non-recursive). +fn find_core_files(dir: &AbsPathBuf) -> Vec { + let Ok(entries) = fs::read_dir(dir.as_path()) else { + return Vec::new(); + }; + let mut core_files = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() + && path.extension().is_some_and(|ext| ext == "core") + && let Some(abs) = utils::paths::abs_path_buf_from_path_buf(path) + { + core_files.push(abs); + } + } + core_files.sort(); + core_files } #[cfg(test)] @@ -116,7 +236,9 @@ mod tests { use utils::test_support::TestDir; - use super::{MANIFEST_FILE_NAME, ProjectManifest, ProjectManifestFileName}; + use super::{ + MANIFEST_FILE_NAME, ProjectManifest, ProjectManifestFileName, persist_fusesoc_selection, + }; #[test] fn from_path_does_not_use_parent_manifest() { @@ -175,4 +297,64 @@ mod tests { assert!(error.to_string().contains("must be a directory")); } + + #[test] + fn from_path_discovers_single_core_file() { + let root = TestDir::new("fusesoc-single-core"); + let core_path = root.join("top.core"); + fs::write(&core_path, "CAPI=2:\nname: v:l:top:1.0\n").unwrap(); + + let root_abs = root.path().to_path_buf(); + let manifest = ProjectManifest::from_path(&root_abs).unwrap(); + + assert_eq!(manifest, ProjectManifest::FuseSocCore(core_path)); + } + + #[test] + fn from_path_discovers_multiple_core_files_as_dir() { + let root = TestDir::new("fusesoc-ambiguous-cores"); + fs::write(root.join("a.core"), "CAPI=2:\nname: v:l:a:1.0\n").unwrap(); + fs::write(root.join("b.core"), "CAPI=2:\nname: v:l:b:1.0\n").unwrap(); + + let root_abs = root.path().to_path_buf(); + let manifest = ProjectManifest::from_path(&root_abs).unwrap(); + + // Multiple cores — the client will ask the user to select the root. + assert_eq!(manifest, ProjectManifest::FuseSocCoreDir(root_abs)); + } + + #[test] + fn persists_selected_core_and_target_in_vide_toml() { + let root = TestDir::new("fusesoc-persist-core-target"); + let core_path = root.join("top.core"); + fs::write(&core_path, "CAPI=2:\nname: v:l:top:1.0\n").unwrap(); + + persist_fusesoc_selection(&root.path().to_path_buf(), &core_path, Some("lint")).unwrap(); + + let manifest = fs::read_to_string(root.join(MANIFEST_FILE_NAME)).unwrap(); + assert!(manifest.contains("[fusesoc]\ncore = \"top.core\"\ntarget = \"lint\"")); + } + + #[test] + fn from_path_prefers_vide_toml_over_core() { + let root = TestDir::new("fusesoc-and-toml"); + fs::write(root.join("top.core"), "CAPI=2:\nname: v:l:top:1.0\n").unwrap(); + let toml_path = root.join(MANIFEST_FILE_NAME); + fs::write(&toml_path, r#"top_modules = ["top"]"#).unwrap(); + + let root_abs = root.path().to_path_buf(); + let manifest = ProjectManifest::from_path(&root_abs).unwrap(); + + assert_eq!(manifest, ProjectManifest::Toml(toml_path)); + } + + #[test] + fn from_path_accepts_core_file_directly() { + let root = TestDir::new("fusesoc-direct"); + let core_path = root.join("top.core"); + fs::write(&core_path, "CAPI=2:\nname: v:l:top:1.0\n").unwrap(); + + let manifest = ProjectManifest::from_path(&core_path).unwrap(); + assert_eq!(manifest, ProjectManifest::FuseSocCore(core_path)); + } } diff --git a/crates/project-model/src/toml_workspace.rs b/crates/project-model/src/toml_workspace.rs index 17609367d..1e82364cd 100644 --- a/crates/project-model/src/toml_workspace.rs +++ b/crates/project-model/src/toml_workspace.rs @@ -123,6 +123,37 @@ struct TomlManifestSchema { ) )] pub exclude: Vec, + /// FuseSoC CAPI2 integration. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fusesoc: Option, +} + +/// Configuration for FuseSoC .core loading from a vide.toml `[fusesoc]` +/// section. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[cfg_attr(feature = "manifest-schema", derive(schemars::JsonSchema))] +#[serde(deny_unknown_fields)] +pub struct FuseSocTomlConfig { + /// `.core` file name (relative to the workspace root) or VLNV string. + #[cfg_attr( + feature = "manifest-schema", + schemars(description = "Core file name (relative to workspace root) or VLNV string") + )] + pub core: String, + /// Target name to select. Vide requires this to be explicitly selected. + #[cfg_attr( + feature = "manifest-schema", + schemars(description = "Target name to select. This must be explicitly selected.") + )] + #[serde(default)] + pub target: Option, + /// Use-flags for CAPI2 conditional expression evaluation. + #[cfg_attr( + feature = "manifest-schema", + schemars(description = "Use-flags for CAPI2 conditional expression evaluation.") + )] + #[serde(default)] + pub flags: Vec, } #[cfg(feature = "manifest-schema")] @@ -208,6 +239,7 @@ pub struct TomlWorkspace { pub include_dirs: Option>, pub libraries: Vec, pub exclude_patterns: Vec, + pub fusesoc: Option, } impl TomlWorkspace { @@ -235,6 +267,7 @@ impl TomlWorkspace { .map(|path| workspace_root.absolutize(path)) .collect::>(); let exclude_patterns = toml_schema.exclude; + let fusesoc = toml_schema.fusesoc; Ok(TomlWorkspace { manifest_path: toml.clone(), @@ -245,6 +278,7 @@ impl TomlWorkspace { include_dirs, libraries, exclude_patterns, + fusesoc, }) } } diff --git a/editors/vscode/l10n/bundle.l10n.zh-cn.json b/editors/vscode/l10n/bundle.l10n.zh-cn.json index f33d0c948..5e2f8358d 100644 --- a/editors/vscode/l10n/bundle.l10n.zh-cn.json +++ b/editors/vscode/l10n/bundle.l10n.zh-cn.json @@ -10,6 +10,7 @@ "Loading project configuration": "正在加载项目配置", "Project manifest loaded": "项目配置文件已加载", "{0} project manifests loaded": "已加载 {0} 个项目配置文件", + "Select the FuseSoC project core and target": "选择 FuseSoC 项目的 core 和 target", "No project manifest": "没有项目配置文件", "Project configuration failed": "项目配置失败", "Show Vide Status": "显示 Vide 状态", @@ -43,6 +44,15 @@ "Open Vide Project Manifest": "打开 Vide 项目配置文件", "Failed to reload Vide project configuration: {0}": "无法重新加载 Vide 项目配置:{0}", "$(error) Project Configuration Error": "$(error) 项目配置错误", + "$(list-selection) Select FuseSoC Project": "$(list-selection) 选择 FuseSoC 项目", + "Choose the root core and target for the Vide project": "选择 Vide 项目的根 core 和 target", + "Select FuseSoC Root Core": "选择 FuseSoC 根 core", + "Multiple .core files were found; choose the project root core": "发现多个 .core 文件,请选择项目根 core", + "No FuseSoC .core files were found in the workspace.": "工作区中没有找到 FuseSoC .core 文件。", + "The selected FuseSoC core does not define any targets.": "选择的 FuseSoC core 没有定义任何 target。", + "Select FuseSoC Target": "选择 FuseSoC target", + "Choose the target to use for this Vide project": "选择 Vide 项目要使用的 target", + "Failed to select the FuseSoC project: {0}": "无法选择 FuseSoC 项目:{0}", "$(go-to-file) Open Manifest": "$(go-to-file) 打开项目配置文件", "{0} manifests": "{0} 个项目配置文件", "$(new-file) Create Manifest": "$(new-file) 创建项目配置文件", diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 39560c368..2ffceeccd 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -29,7 +29,8 @@ "activationEvents": [ "onLanguage:verilog", "onLanguage:systemverilog", - "workspaceContains:**/vide.toml" + "workspaceContains:**/vide.toml", + "workspaceContains:**/*.core" ], "main": "./dist/extension.js", "browser": "./dist/browser/extension.js", diff --git a/editors/vscode/src/browser/extension.ts b/editors/vscode/src/browser/extension.ts index f792fa72a..ade03cd1e 100644 --- a/editors/vscode/src/browser/extension.ts +++ b/editors/vscode/src/browser/extension.ts @@ -8,8 +8,10 @@ import { isProjectSourceFileName, } from "../projectConfigCommon"; import { + listFuseSocTargetsRequest, projectStatusNotification, reloadWorkspaceCommand, + selectFuseSocProjectRequest, showOutputCommand, showStatusCommand, VideStatusController, @@ -42,6 +44,14 @@ let videStatusController: VideStatusController | undefined; let restartChain: Promise = Promise.resolve(); let workspaceRestartTimer: ReturnType | undefined; +interface FuseSocTargetInfo { + name: string; + description?: string; + defaultTool?: string; + flow?: string; + hasToplevel: boolean; +} + function log(message: string): void { outputChannel?.appendLine(message); } @@ -57,6 +67,86 @@ function showOutput(): void { requireOutputChannel().show(true); } +async function selectFuseSocProject( + context: vscode.ExtensionContext, + workspaceUri: string, +): Promise { + if (!client) { + throw new Error(vscode.l10n.t("Vide language server is not running.")); + } + + const workspace = vscode.Uri.parse(workspaceUri); + const entries = await vscode.workspace.fs.readDirectory(workspace); + const candidates = entries + .filter( + ([name, type]) => + type === vscode.FileType.File && name.toLowerCase().endsWith(".core"), + ) + .map(([name]) => vscode.Uri.joinPath(workspace, name).toString()) + .sort(); + if (candidates.length === 0) { + throw new Error(vscode.l10n.t("No FuseSoC .core files were found in the workspace.")); + } + + const coreUri = + candidates.length === 1 + ? candidates[0] + : ( + await vscode.window.showQuickPick( + candidates.map((uri) => ({ + label: vscode.Uri.parse(uri).path.split("/").pop() ?? uri, + description: vscode.Uri.parse(uri).path, + uri, + })), + { + title: vscode.l10n.t("Select FuseSoC Root Core"), + placeHolder: vscode.l10n.t( + "Multiple .core files were found; choose the project root core", + ), + }, + ) + )?.uri; + if (!coreUri) { + return; + } + + const targets = (await client.request("workspace/executeCommand", { + command: listFuseSocTargetsRequest, + arguments: [{ workspaceUri, coreUri }], + })) as FuseSocTargetInfo[]; + if (targets.length === 0) { + throw new Error(vscode.l10n.t("The selected FuseSoC core does not define any targets.")); + } + + const selectedTarget = await vscode.window.showQuickPick( + targets.map((target) => ({ + label: target.name, + description: [ + target.description, + target.flow ? `flow: ${target.flow}` : undefined, + target.defaultTool ? `tool: ${target.defaultTool}` : undefined, + target.hasToplevel ? undefined : "no toplevel", + ] + .filter((part): part is string => Boolean(part)) + .join(" · "), + target: target.name, + })), + { + title: vscode.l10n.t("Select FuseSoC Target"), + placeHolder: vscode.l10n.t("Choose the target to use for this Vide project"), + }, + ); + if (!selectedTarget) { + return; + } + + await client.request("workspace/executeCommand", { + command: selectFuseSocProjectRequest, + arguments: [{ workspaceUri, coreUri, target: selectedTarget.target }], + }); + await queueRestart(context, "FuseSoC project selection"); +} + async function showLanguageServerErrorMessage(message: string): Promise { const showOutputAction = vscode.l10n.t("Show Output"); const selection = await vscode.window.showErrorMessage( @@ -291,6 +381,7 @@ export async function activate( videStatusController = new VideStatusController({ createManifest: (rootUris) => createProjectConfigsFromRootUris(context, rootUris), + selectFuseSocProject: (workspaceUri) => selectFuseSocProject(context, workspaceUri), profileDiagnostics: profileTraceEnabled ? () => showUnavailableInBrowser("Diagnostics profiling") : undefined, diff --git a/editors/vscode/src/browser/shared/document-selector.ts b/editors/vscode/src/browser/shared/document-selector.ts index b5066bc7b..83767e7e0 100644 --- a/editors/vscode/src/browser/shared/document-selector.ts +++ b/editors/vscode/src/browser/shared/document-selector.ts @@ -1,4 +1,8 @@ -import { PROJECT_CONFIG_FILE_GLOB, PROJECT_CONFIG_FILE_NAME } from "../../projectConfigCommon"; +import { + FUSE_SOC_CORE_FILE_GLOB, + PROJECT_CONFIG_FILE_GLOB, + PROJECT_CONFIG_FILE_NAME, +} from "../../projectConfigCommon"; export function videDocumentSelector(rootUri: string): Array<{ scheme: "file"; @@ -10,10 +14,14 @@ export function videDocumentSelector(rootUri: string): Array<{ const manifestPattern = rootPath ? `${decodeURIComponent(rootPath)}/**/${PROJECT_CONFIG_FILE_NAME}` : PROJECT_CONFIG_FILE_GLOB; + const corePattern = rootPath + ? `${decodeURIComponent(rootPath)}/**/*.core` + : FUSE_SOC_CORE_FILE_GLOB; return [ { scheme: "file", language: "systemverilog", pattern }, { scheme: "file", language: "verilog", pattern }, { scheme: "file", pattern: manifestPattern }, + { scheme: "file", pattern: corePattern }, ]; } diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index 3b5ccd683..334e055b7 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -17,6 +17,7 @@ import { profileDiagnosticsCommand, registerProfilingCommand } from './profiling import { serverInitializationOptions } from './initializationOptions'; import { DEFAULT_PROJECT_CONFIG_TEXT, + FUSE_SOC_CORE_FILE_GLOB, PROJECT_CONFIG_FILE_GLOB, PROJECT_CONFIG_FILE_NAMES, PROJECT_CONFIG_FILE_NAME, @@ -26,8 +27,10 @@ import { import { registerQiheOptionsCommand } from './qiheOptions'; import { projectStatusNotification, + listFuseSocTargetsRequest, reloadWorkspaceCommand, reloadWorkspaceRequest, + selectFuseSocProjectRequest, showOutputCommand, showStatusCommand, VideStatusController, @@ -46,6 +49,7 @@ const showServerVersionCommand = 'vide.showServerVersion'; const showQiheOutputCommand = 'vide.showQiheOutput'; const runQiheAnalysisCommand = 'vide.runQiheAnalysis'; const runQiheAnalysisRequest = 'vide.server.runQiheAnalysis'; +const selectFuseSocProjectCommand = 'vide.selectFuseSocProject'; const renameExpansionInfoRequest = 'vide.server.renameExpansionInfo'; const expandedRenameRequest = 'vide.server.expandedRename'; const renameConflictInfoRequest = 'vide.server.renameConflictInfo'; @@ -791,6 +795,7 @@ async function createClient(context: vscode.ExtensionContext): Promise { } } +interface FuseSocProjectCommandArgs { + workspaceUri: string; + coreUri?: string; + target?: string; +} + +interface FuseSocTargetInfo { + name: string; + description?: string; + defaultTool?: string; + flow?: string; + hasToplevel: boolean; +} + +async function selectFuseSocProject(args: FuseSocProjectCommandArgs): Promise { + if (!client) { + throw new Error(vscode.l10n.t('Vide language server is not running.')); + } + + let coreUri = args.coreUri; + if (!coreUri) { + const workspace = vscode.Uri.parse(args.workspaceUri); + const entries = await vscode.workspace.fs.readDirectory(workspace); + const candidates = entries + .filter( + ([name, type]) => + type === vscode.FileType.File && path.extname(name).toLowerCase() === '.core', + ) + .map(([name]) => vscode.Uri.joinPath(workspace, name).toString()) + .sort(); + if (candidates.length === 0) { + throw new Error(vscode.l10n.t('No FuseSoC .core files were found in the workspace.')); + } + if (candidates.length === 1) { + coreUri = candidates[0]; + } else { + const selected = await vscode.window.showQuickPick( + candidates.map((uri) => ({ + label: path.basename(vscode.Uri.parse(uri).fsPath), + description: vscode.Uri.parse(uri).fsPath, + uri, + })), + { + title: vscode.l10n.t('Select FuseSoC Root Core'), + placeHolder: vscode.l10n.t( + 'Multiple .core files were found; choose the project root core', + ), + }, + ); + if (!selected) { + return; + } + coreUri = selected.uri; + } + } + + let target = args.target; + if (!target) { + const targets = await client.sendRequest('workspace/executeCommand', { + command: listFuseSocTargetsRequest, + arguments: [{ workspaceUri: args.workspaceUri, coreUri }], + }); + if (targets.length === 0) { + throw new Error(vscode.l10n.t('The selected FuseSoC core does not define any targets.')); + } + const selectedTarget = await vscode.window.showQuickPick( + targets.map((item) => ({ + label: item.name, + description: targetDescription(item), + target: item.name, + })), + { + title: vscode.l10n.t('Select FuseSoC Target'), + placeHolder: vscode.l10n.t('Choose the target to use for this Vide project'), + }, + ); + if (!selectedTarget) { + return; + } + target = selectedTarget.target; + } + + await client.sendRequest('workspace/executeCommand', { + command: selectFuseSocProjectRequest, + arguments: [{ workspaceUri: args.workspaceUri, coreUri, target }], + }); +} + +function targetDescription(target: FuseSocTargetInfo): string { + return [ + target.description, + target.flow ? `flow: ${target.flow}` : undefined, + target.defaultTool ? `tool: ${target.defaultTool}` : undefined, + target.hasToplevel ? undefined : 'no toplevel', + ] + .filter((part): part is string => Boolean(part)) + .join(' · '); +} + async function runQiheAnalysis(resource: unknown): Promise { const targetUri = qiheAnalysisTargetUri(resource); if (!targetUri) { @@ -991,6 +1095,7 @@ export async function activate(context: vscode.ExtensionContext): Promise const profileTraceEnabled = isProfileTraceEnabled(context); videStatusController = new VideStatusController({ createManifest: (rootUris) => createProjectConfigsFromRootUris(context, rootUris), + selectFuseSocProject: (workspaceUri) => selectFuseSocProject({ workspaceUri }), profileDiagnostics: profileTraceEnabled ? async () => { await vscode.commands.executeCommand(profileDiagnosticsCommand); @@ -1040,6 +1145,17 @@ export async function activate(context: vscode.ExtensionContext): Promise ); context.subscriptions.push(showVersionRegistration); + const selectFuseSocProjectRegistration = vscode.commands.registerCommand( + selectFuseSocProjectCommand, + async (args: unknown) => { + if (!args || typeof args !== 'object') { + throw new Error('FuseSoC project selection command requires an object argument.'); + } + await selectFuseSocProject(args as FuseSocProjectCommandArgs); + }, + ); + context.subscriptions.push(selectFuseSocProjectRegistration); + const runQiheRegistration = vscode.commands.registerCommand( runQiheAnalysisCommand, async (resource) => { diff --git a/editors/vscode/src/projectConfigCommon.ts b/editors/vscode/src/projectConfigCommon.ts index c7d91b087..6448baf7d 100644 --- a/editors/vscode/src/projectConfigCommon.ts +++ b/editors/vscode/src/projectConfigCommon.ts @@ -13,6 +13,7 @@ export { export const PROJECT_CONFIG_FILE_NAME = "vide.toml"; export const PROJECT_CONFIG_FILE_NAMES = [PROJECT_CONFIG_FILE_NAME] as const; export const PROJECT_CONFIG_FILE_GLOB = `**/${PROJECT_CONFIG_FILE_NAME}`; +export const FUSE_SOC_CORE_FILE_GLOB = "**/*.core"; export const PROJECT_SOURCE_FILE_EXTENSIONS = [ ".v", ".sv", diff --git a/editors/vscode/src/status.ts b/editors/vscode/src/status.ts index 097c4cd19..6393d7fee 100644 --- a/editors/vscode/src/status.ts +++ b/editors/vscode/src/status.ts @@ -73,7 +73,17 @@ export function getServerStatusPresentation( } } -export type ProjectStatusState = 'loading' | 'loaded' | 'none' | 'error'; +export type ProjectStatusState = + | 'loading' + | 'loaded' + | 'selectionRequired' + | 'none' + | 'error'; + +export interface FuseSocCoreSelection { + workspaceUri: string; + coreUris: string[]; +} export interface ProjectStatus { state: ProjectStatusState; @@ -81,6 +91,7 @@ export interface ProjectStatus { unconfiguredRootUris: string[]; workspaceCount: number; errors: string[]; + fusesocCoreSelections?: FuseSocCoreSelection[]; message?: string; } @@ -104,6 +115,7 @@ export function asProjectStatus(value: unknown): ProjectStatus | undefined { if ( state !== 'loading' && state !== 'loaded' && + state !== 'selectionRequired' && state !== 'none' && state !== 'error' ) { @@ -113,19 +125,21 @@ export function asProjectStatus(value: unknown): ProjectStatus | undefined { const manifestUris = asStringArray(params.manifestUris); const unconfiguredRootUris = asStringArray(params.unconfiguredRootUris); const errors = asStringArray(params.errors); + const fusesocCoreSelections = asFuseSocCoreSelections(params.fusesocCoreSelections); const workspaceCount = params.workspaceCount; const message = params.message; if ( !manifestUris || !unconfiguredRootUris || !errors || + (params.fusesocCoreSelections !== undefined && !fusesocCoreSelections) || typeof workspaceCount !== 'number' || (message !== undefined && typeof message !== 'string') ) { return undefined; } - return { + const status: ProjectStatus = { state, manifestUris, unconfiguredRootUris, @@ -133,6 +147,10 @@ export function asProjectStatus(value: unknown): ProjectStatus | undefined { errors, message, }; + if (fusesocCoreSelections) { + status.fusesocCoreSelections = fusesocCoreSelections; + } + return status; } export type ProjectStatusPresentation = LanguageStatusPresentation; @@ -142,6 +160,7 @@ export interface ProjectStatusMessages { loadingDetail: string; loadedOneManifestDetail: string; loadedManyManifestsDetail: (count: number) => string; + selectionRequiredDetail: string; noManifestDetail: string; errorDetail: string; } @@ -151,6 +170,7 @@ export const defaultProjectStatusMessages: ProjectStatusMessages = { loadingDetail: 'Loading project configuration', loadedOneManifestDetail: 'Project manifest loaded', loadedManyManifestsDetail: (count) => `${count} project manifests loaded`, + selectionRequiredDetail: 'Select the FuseSoC project core and target', noManifestDetail: 'No project manifest', errorDetail: 'Project configuration failed', }; @@ -177,6 +197,13 @@ export function getProjectStatusPresentation( severity: 'information', busy: false, }; + case 'selectionRequired': + return { + text: messages.text, + detail: messages.selectionRequiredDetail, + severity: 'warning', + busy: false, + }; case 'none': return { text: messages.text, @@ -257,3 +284,27 @@ function asStringArray(value: unknown): string[] | undefined { ? value : undefined; } + +function asFuseSocCoreSelections(value: unknown): FuseSocCoreSelection[] | undefined { + if (value === undefined) { + return undefined; + } + if (!Array.isArray(value)) { + return undefined; + } + + const selections: FuseSocCoreSelection[] = []; + for (const item of value) { + if (!item || typeof item !== 'object') { + return undefined; + } + const selection = item as Record; + const workspaceUri = selection.workspaceUri; + const coreUris = asStringArray(selection.coreUris); + if (typeof workspaceUri !== 'string' || !coreUris) { + return undefined; + } + selections.push({ workspaceUri, coreUris }); + } + return selections; +} diff --git a/editors/vscode/src/videStatus.ts b/editors/vscode/src/videStatus.ts index d1bd05b45..51be02112 100644 --- a/editors/vscode/src/videStatus.ts +++ b/editors/vscode/src/videStatus.ts @@ -8,6 +8,7 @@ import { type LanguageStatusPresentation, type ProjectStatus, type ProjectStatusMessages, + type FuseSocCoreSelection, type ServerStatus, type ServerStatusMessages, type VideStatusMessages, @@ -19,10 +20,13 @@ export const reloadWorkspaceCommand = 'vide.reloadWorkspace'; export const showOutputCommand = 'vide.showOutput'; export const showStatusCommand = 'vide.showStatus'; export const reloadWorkspaceRequest = 'vide.server.reloadWorkspace'; +export const selectFuseSocProjectRequest = 'vide.server.selectFuseSocProject'; +export const listFuseSocTargetsRequest = 'vide.server.listFuseSocTargets'; export const projectStatusNotification = 'vide/projectStatus'; export interface VideStatusActions { createManifest: (rootUris: readonly string[]) => Promise; + selectFuseSocProject: (workspaceUri: string) => Promise; profileDiagnostics?: () => Promise; reloadProject: () => Promise; restartServer: () => Promise; @@ -35,6 +39,7 @@ export class VideStatusController implements vscode.Disposable { private projectStatus = initialProjectStatus(); private serverStatus: ServerStatus = 'stopped'; private serverDetail: string | undefined; + private readonly pendingProjectSelections = new Set(); constructor(private readonly actions: VideStatusActions) { this.item = vscode.window.createStatusBarItem( @@ -66,6 +71,9 @@ export class VideStatusController implements vscode.Disposable { updateProjectStatus(status: ProjectStatus): void { this.projectStatus = status; this.update(); + if (status.fusesocCoreSelections?.length) { + void this.promptForFuseSocProjectSelections(status.fusesocCoreSelections); + } } updateServerStatus(status: ServerStatus, detail?: string): void { @@ -103,6 +111,9 @@ export class VideStatusController implements vscode.Disposable { case 'createManifest': await this.actions.createManifest(status.unconfiguredRootUris); break; + case 'selectFuseSocProject': + await this.promptForFuseSocProjectSelections(status.fusesocCoreSelections ?? []); + break; case 'profileDiagnostics': await this.actions.profileDiagnostics?.(); break; @@ -147,6 +158,16 @@ export class VideStatusController implements vscode.Disposable { }); } + if (status.fusesocCoreSelections?.length) { + items.push({ + label: vscode.l10n.t('$(list-selection) Select FuseSoC Project'), + description: vscode.l10n.t( + 'Choose the root core and target for the Vide project', + ), + action: 'selectFuseSocProject', + }); + } + if (status.manifestUris.length > 0) { items.push({ label: vscode.l10n.t('$(go-to-file) Open Manifest'), @@ -197,12 +218,40 @@ export class VideStatusController implements vscode.Disposable { return items; } + + private async promptForFuseSocProjectSelections( + selections: readonly FuseSocCoreSelection[], + ): Promise { + const action = this.actions.selectFuseSocProject; + + for (const selection of selections) { + const key = `${selection.workspaceUri}\0${selection.coreUris.join('\0')}`; + if (this.pendingProjectSelections.has(key)) { + continue; + } + this.pendingProjectSelections.add(key); + + try { + await action(selection.workspaceUri); + } catch (error) { + const message = vscode.l10n.t( + 'Failed to select the FuseSoC project: {0}', + error instanceof Error ? error.message : String(error), + ); + this.actions.log(`[ERROR] ${message}`); + void vscode.window.showErrorMessage(message); + } finally { + this.pendingProjectSelections.delete(key); + } + } + } } type VideStatusQuickPickItem = vscode.QuickPickItem & { action: | 'openManifest' | 'createManifest' + | 'selectFuseSocProject' | 'profileDiagnostics' | 'reloadProject' | 'restartServer' @@ -262,6 +311,7 @@ function localizedProjectStatusMessages(): ProjectStatusMessages { loadedOneManifestDetail: vscode.l10n.t('Project manifest loaded'), loadedManyManifestsDetail: (count) => vscode.l10n.t('{0} project manifests loaded', count), + selectionRequiredDetail: vscode.l10n.t('Select the FuseSoC project core and target'), noManifestDetail: vscode.l10n.t('No project manifest'), errorDetail: vscode.l10n.t('Project configuration failed'), }; diff --git a/editors/vscode/test/projectConfig.test.ts b/editors/vscode/test/projectConfig.test.ts index a3c2f0e62..f12cff1ac 100644 --- a/editors/vscode/test/projectConfig.test.ts +++ b/editors/vscode/test/projectConfig.test.ts @@ -5,6 +5,7 @@ import * as path from 'node:path'; import { DEFAULT_PROJECT_CONFIG_TEXT, + FUSE_SOC_CORE_FILE_GLOB, PROJECT_CONFIG_FILE_GLOB, PROJECT_CONFIG_SCHEMA_PATH, PROJECT_CONFIG_SCHEMA_URL, @@ -18,6 +19,7 @@ import { test('uses the Vide project config file name', () => { assert.equal(PROJECT_CONFIG_FILE_NAME, 'vide.toml'); assert.equal(PROJECT_CONFIG_FILE_GLOB, '**/vide.toml'); + assert.equal(FUSE_SOC_CORE_FILE_GLOB, '**/*.core'); }); test('resolves project config paths under workspace roots', () => { @@ -34,12 +36,13 @@ test('recognizes project config file names', () => { assert.equal(isProjectConfigFileName('other.toml'), false); }); -test('activates the extension for project manifests', () => { +test('activates the extension for FuseSoC projects', () => { const packageJson = JSON.parse( fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'), ) as { activationEvents?: string[] }; assert.ok(packageJson.activationEvents?.includes('workspaceContains:**/vide.toml')); + assert.ok(packageJson.activationEvents?.includes('workspaceContains:**/*.core')); }); test('recognizes Verilog and SystemVerilog source file names', () => { diff --git a/editors/vscode/test/status.test.ts b/editors/vscode/test/status.test.ts index d07de8d7c..e58c601c1 100644 --- a/editors/vscode/test/status.test.ts +++ b/editors/vscode/test/status.test.ts @@ -89,6 +89,15 @@ test('maps project status to language status presentations', () => { getProjectStatusPresentation({ ...baseStatus, state: 'error', errors: ['bad toml'] }).severity, 'error', ); + assert.deepEqual( + getProjectStatusPresentation({ ...baseStatus, state: 'selectionRequired' }), + { + text: 'Vide', + detail: 'Select the FuseSoC project core and target', + severity: 'warning', + busy: false, + }, + ); }); test('parses project status notifications defensively', () => { @@ -124,6 +133,29 @@ test('uses loading as the initial project status', () => { }); }); +test('parses FuseSoC core selection candidates', () => { + const status = asProjectStatus({ + state: 'selectionRequired', + manifestUris: ['file:///workspace'], + unconfiguredRootUris: [], + workspaceCount: 0, + errors: ['multiple FuseSoC cores'], + fusesocCoreSelections: [ + { + workspaceUri: 'file:///workspace', + coreUris: ['file:///workspace/a.core', 'file:///workspace/b.core'], + }, + ], + }); + + assert.deepEqual(status?.fusesocCoreSelections, [ + { + workspaceUri: 'file:///workspace', + coreUris: ['file:///workspace/a.core', 'file:///workspace/b.core'], + }, + ]); +}); + test('selects the main Vide status from lifecycle order', () => { const projectStatus: ProjectStatus = { state: 'loaded', diff --git a/schemas/v1/vide.schema.json b/schemas/v1/vide.schema.json index 308626f94..1d5c4dbea 100644 --- a/schemas/v1/vide.schema.json +++ b/schemas/v1/vide.schema.json @@ -87,10 +87,53 @@ "**/*_bb.v" ] ] + }, + "fusesoc": { + "description": "FuseSoC CAPI2 integration.", + "anyOf": [ + { + "$ref": "#/$defs/FuseSocTomlConfig" + }, + { + "type": "null" + } + ] } }, "additionalProperties": false, "x-tombi-table-keys-order": "schema", + "$defs": { + "FuseSocTomlConfig": { + "description": "Configuration for FuseSoC .core loading from a vide.toml `[fusesoc]`\nsection.", + "type": "object", + "properties": { + "core": { + "description": "Core file name (relative to workspace root) or VLNV string", + "type": "string" + }, + "target": { + "description": "Target name to select. This must be explicitly selected.", + "type": [ + "string", + "null" + ], + "default": null + }, + "flags": { + "description": "Use-flags for CAPI2 conditional expression evaluation.", + "type": "array", + "items": { + "type": "string" + }, + "default": [] + } + }, + "additionalProperties": false, + "required": [ + "core" + ] + } + }, "x-vide-manifest-names": { "primary": "vide.toml" } diff --git a/src/config/caps.rs b/src/config/caps.rs index 94e3d45ff..10b07d3fd 100644 --- a/src/config/caps.rs +++ b/src/config/caps.rs @@ -15,8 +15,9 @@ use utils::{line_index::WideEncoding, lines::PositionEncoding}; use crate::{ config::Config, lsp_ext::ext::{ - self, EXPANDED_RENAME_COMMAND, RELOAD_WORKSPACE_COMMAND, RENAME_CONFLICT_INFO_COMMAND, - RENAME_EXPANSION_INFO_COMMAND, RUN_QIHE_ANALYSIS_COMMAND, + self, EXPANDED_RENAME_COMMAND, LIST_FUSESOC_TARGETS_COMMAND, RELOAD_WORKSPACE_COMMAND, + RENAME_CONFLICT_INFO_COMMAND, RENAME_EXPANSION_INFO_COMMAND, RUN_QIHE_ANALYSIS_COMMAND, + SELECT_FUSESOC_PROJECT_COMMAND, }, }; @@ -320,6 +321,8 @@ impl Config { commands: vec![ RUN_QIHE_ANALYSIS_COMMAND.to_string(), RELOAD_WORKSPACE_COMMAND.to_string(), + LIST_FUSESOC_TARGETS_COMMAND.to_string(), + SELECT_FUSESOC_PROJECT_COMMAND.to_string(), RENAME_EXPANSION_INFO_COMMAND.to_string(), EXPANDED_RENAME_COMMAND.to_string(), RENAME_CONFLICT_INFO_COMMAND.to_string(), diff --git a/src/global_state/handlers/request/commands.rs b/src/global_state/handlers/request/commands.rs index 71de34693..a33d10f7c 100644 --- a/src/global_state/handlers/request/commands.rs +++ b/src/global_state/handlers/request/commands.rs @@ -1,13 +1,14 @@ -use serde::de::DeserializeOwned; +use serde::de::DeserializeOwned; use crate::{ i18n::keys, lsp_ext::{ ext::{ - EXPANDED_RENAME_COMMAND, ExpandedRenameParams, RELOAD_WORKSPACE_COMMAND, - RENAME_CONFLICT_INFO_COMMAND, RENAME_EXPANSION_INFO_COMMAND, RUN_QIHE_ANALYSIS_COMMAND, - RenameConflictInfoParams, RenameConflictInfoResult, RenameExpansionInfoParams, - RenameExpansionInfoResult, RunQiheAnalysisParams, + EXPANDED_RENAME_COMMAND, ExpandedRenameParams, LIST_FUSESOC_TARGETS_COMMAND, + ListFuseSocTargetsParams, RELOAD_WORKSPACE_COMMAND, RENAME_CONFLICT_INFO_COMMAND, + RENAME_EXPANSION_INFO_COMMAND, RUN_QIHE_ANALYSIS_COMMAND, RenameConflictInfoParams, + RenameConflictInfoResult, RenameExpansionInfoParams, RenameExpansionInfoResult, + RunQiheAnalysisParams, SELECT_FUSESOC_PROJECT_COMMAND, SelectFuseSocProjectParams, }, from_proto, to_proto, }, @@ -31,6 +32,63 @@ fn handle_reload_workspace_command( Ok(None) } +fn validate_fusesoc_selection_workspace( + state: &mut crate::global_state::GlobalState, + workspace_uri: &lsp_types::Url, + core_uri: &lsp_types::Url, +) -> anyhow::Result<(utils::paths::AbsPathBuf, utils::paths::AbsPathBuf)> { + let workspace_root = from_proto::abs_path(workspace_uri)?; + anyhow::ensure!( + state.config_state.config.workspace_roots.iter().any(|root| root == &workspace_root), + "FuseSoC workspace root is not an open workspace: {workspace_root}" + ); + let core_path = from_proto::abs_path(core_uri)?; + anyhow::ensure!( + project_model::project_manifest::fusesoc_core_candidates(&workspace_root) + .iter() + .any(|candidate| candidate == &core_path), + "selected FuseSoC core is not a direct .core candidate in {workspace_root}: {core_path}" + ); + Ok((workspace_root, core_path)) +} + +fn handle_select_fusesoc_project_command( + state: &mut crate::global_state::GlobalState, + params: lsp_types::ExecuteCommandParams, +) -> anyhow::Result> { + let params = extract_execute_arg::(state, ¶ms)?; + let (workspace_root, core_path) = + validate_fusesoc_selection_workspace(state, ¶ms.workspace_uri, ¶ms.core_uri)?; + let manifest_path = project_model::project_manifest::persist_fusesoc_selection( + &workspace_root, + &core_path, + params.target.as_deref(), + )?; + + tracing::info!( + workspace_root = %workspace_root, + core_path = %core_path, + target = ?params.target, + manifest_path = %manifest_path, + "persisted FuseSoC project selection" + ); + let config = triomphe::Arc::make_mut(&mut state.config_state.config); + config.refresh_project_manifests(); + state.request_workspace_reload("FuseSoC root core selected"); + Ok(None) +} + +fn handle_list_fusesoc_targets_command( + state: &mut crate::global_state::GlobalState, + params: lsp_types::ExecuteCommandParams, +) -> anyhow::Result> { + let params = extract_execute_arg::(state, ¶ms)?; + let (_, core_path) = + validate_fusesoc_selection_workspace(state, ¶ms.workspace_uri, ¶ms.core_uri)?; + let targets = fusesoc_model::cli::read_core_targets(&core_path)?; + Ok(Some(serde_json::to_value(targets)?)) +} + fn handle_rename_expansion_info_command( state: &mut crate::global_state::GlobalState, params: lsp_types::ExecuteCommandParams, @@ -99,6 +157,8 @@ pub(crate) fn handle_execute_command( match params.command.as_str() { RUN_QIHE_ANALYSIS_COMMAND => handle_qihe_analysis_command(state, params), RELOAD_WORKSPACE_COMMAND => handle_reload_workspace_command(state), + LIST_FUSESOC_TARGETS_COMMAND => handle_list_fusesoc_targets_command(state, params), + SELECT_FUSESOC_PROJECT_COMMAND => handle_select_fusesoc_project_command(state, params), RENAME_EXPANSION_INFO_COMMAND => handle_rename_expansion_info_command(state, params), EXPANDED_RENAME_COMMAND => handle_expanded_rename_command(state, params), RENAME_CONFLICT_INFO_COMMAND => handle_rename_conflict_info_command(state, params), diff --git a/src/global_state/handlers/request/hints_lens.rs b/src/global_state/handlers/request/hints_lens.rs index 3686e3305..a4690edda 100644 --- a/src/global_state/handlers/request/hints_lens.rs +++ b/src/global_state/handlers/request/hints_lens.rs @@ -1,10 +1,12 @@ use ide::FileRange; use itertools::Itertools; +use serde_json::json; use utils::text_edit::TextRange; use crate::{ global_state::snapshot::GlobalStateSnapshot, - lsp_ext::{from_proto, to_proto}, + i18n::keys, + lsp_ext::{ext::SELECT_FUSESOC_PROJECT_CLIENT_COMMAND, from_proto, to_proto}, }; pub(crate) fn handle_inlay_hint( @@ -37,6 +39,12 @@ pub(crate) fn handle_code_lens( ) -> anyhow::Result>> { let file_id = from_proto::file_id(&snap, ¶ms.text_document.uri)?; let line_info = snap.line_info(file_id)?; + + if let Some(lenses) = fusesoc_code_lenses(&snap, file_id, &line_info)? { + tracing::debug!(lens_count = lenses.len(), "provided FuseSoC code lenses"); + return Ok(Some(lenses)); + } + let config = snap.config.code_lens(); let res = snap @@ -67,6 +75,135 @@ pub(crate) fn handle_code_lens_resolve( Ok(res) } +fn fusesoc_code_lenses( + snap: &GlobalStateSnapshot, + file_id: vfs::FileId, + line_info: &utils::lines::LineInfo, +) -> anyhow::Result>> { + let Some(path) = snap.file_path(file_id) else { + return Ok(None); + }; + let Some(file_name) = path.file_name() else { + return Ok(None); + }; + let text = snap.file_text(file_id)?; + + if path.extension().is_some_and(|extension| extension.eq_ignore_ascii_case("core")) { + return Ok(Some(fusesoc_core_code_lenses(snap, file_id, line_info, &path, &text)?)); + } + if file_name == "vide.toml" { + return fusesoc_manifest_code_lenses(snap, line_info, &path, &text); + } + + Ok(None) +} + +fn fusesoc_core_code_lenses( + snap: &GlobalStateSnapshot, + file_id: vfs::FileId, + line_info: &utils::lines::LineInfo, + core_path: &utils::paths::AbsPathBuf, + text: &str, +) -> anyhow::Result> { + let core_uri = to_proto::url(snap, file_id)?; + let workspace_path = core_path + .as_path() + .parent() + .ok_or_else(|| anyhow::anyhow!("FuseSoC core has no workspace parent: {core_path}"))?; + let workspace_uri = lsp_types::Url::from_file_path(workspace_path).map_err(|()| { + anyhow::anyhow!("FuseSoC workspace path is not a file URL: {workspace_path:?}") + })?; + if !project_model::project_manifest::fusesoc_core_candidates(&workspace_path.to_path_buf()) + .iter() + .any(|candidate| candidate == core_path) + { + return Ok(Vec::new()); + } + + let mut lenses = Vec::new(); + let targets = fusesoc_model::cli::read_core_targets_from_text(core_path, text)?; + for target in targets { + let line = target.source_line; + let Some(range) = line_info.index.range_for_line(line) else { + continue; + }; + lenses.push(lsp_types::CodeLens { + range: to_proto::range(line_info, range), + command: Some(fusesoc_command( + snap.config + .i18n + .format(keys::CODE_LENS_FUSESOC_USE_TARGET, [("target", target.name.clone())]), + workspace_uri.clone(), + Some(core_uri.clone()), + Some(target.name), + )), + data: None, + }); + } + + Ok(lenses) +} + +fn fusesoc_manifest_code_lenses( + snap: &GlobalStateSnapshot, + line_info: &utils::lines::LineInfo, + manifest_path: &utils::paths::AbsPathBuf, + text: &str, +) -> anyhow::Result>> { + let document = text.parse::()?; + if document.get("fusesoc").is_none() { + return Ok(None); + } + let workspace_path = manifest_path + .as_path() + .parent() + .ok_or_else(|| anyhow::anyhow!("Vide manifest has no workspace parent: {manifest_path}"))?; + let workspace_uri = lsp_types::Url::from_file_path(workspace_path).map_err(|()| { + anyhow::anyhow!("Vide workspace path is not a file URL: {workspace_path:?}") + })?; + let lens_line = find_line(text, |line| line.trim() == "[fusesoc]").unwrap_or(0); + let mut lenses = Vec::new(); + if let Some(range) = line_info.index.range_for_line(lens_line) { + lenses.push(lsp_types::CodeLens { + range: to_proto::range(line_info, range), + command: Some(fusesoc_command( + snap.config.i18n.text(keys::CODE_LENS_FUSESOC_CONFIGURE_PROJECT).to_owned(), + workspace_uri.clone(), + None, + None, + )), + data: None, + }); + } + + Ok(Some(lenses)) +} + +fn fusesoc_command( + title: String, + workspace_uri: lsp_types::Url, + core_uri: Option, + target: Option, +) -> lsp_types::Command { + let mut args = serde_json::Map::new(); + args.insert("workspaceUri".to_owned(), json!(workspace_uri)); + if let Some(core_uri) = core_uri { + args.insert("coreUri".to_owned(), json!(core_uri)); + } + if let Some(target) = target { + args.insert("target".to_owned(), json!(target)); + } + lsp_types::Command { + title, + command: SELECT_FUSESOC_PROJECT_CLIENT_COMMAND.to_owned(), + arguments: Some(vec![serde_json::Value::Object(args)]), + } +} + +fn find_line(text: &str, predicate: impl Fn(&str) -> bool) -> Option { + text.lines().enumerate().find_map(|(line, text)| predicate(text).then_some(line as u32)) +} + pub(crate) fn handle_signature_help( snap: GlobalStateSnapshot, params: lsp_types::SignatureHelpParams, diff --git a/src/global_state/project_status.rs b/src/global_state/project_status.rs index 361756c24..1f0d03ac4 100644 --- a/src/global_state/project_status.rs +++ b/src/global_state/project_status.rs @@ -1,9 +1,14 @@ use lsp_types::Url; -use project_model::project_manifest::ProjectManifest; +use project_model::{ + TomlWorkspace, + project_manifest::{ProjectManifest, fusesoc_core_candidates}, +}; use utils::paths::AbsPath; use super::GlobalState; -use crate::lsp_ext::ext::{ProjectStatusNotification, ProjectStatusParams, ProjectStatusState}; +use crate::lsp_ext::ext::{ + FuseSocCoreSelection, ProjectStatusNotification, ProjectStatusParams, ProjectStatusState, +}; impl GlobalState { pub(crate) fn send_loading_project_status(&self, cause: String) { @@ -12,25 +17,36 @@ impl GlobalState { self.workspace.workspaces.len(), Vec::new(), Some(cause), + Vec::new(), ); } pub(crate) fn send_project_status_for_result(&self, workspace_count: usize, errors: &[String]) { - let state = if !errors.is_empty() { + let fusesoc_core_selections = self.fusesoc_core_selections(); + let state = if !fusesoc_core_selections.is_empty() { + ProjectStatusState::SelectionRequired + } else if !errors.is_empty() { ProjectStatusState::Error - } else if self - .config_state - .config - .project_manifests - .iter() - .any(|manifest| matches!(manifest, ProjectManifest::Toml(_))) - { + } else if self.config_state.config.project_manifests.iter().any(|manifest| { + matches!( + manifest, + ProjectManifest::Toml(_) + | ProjectManifest::FuseSocCore(_) + | ProjectManifest::FuseSocCoreDir(_) + ) + }) { ProjectStatusState::Loaded } else { ProjectStatusState::NoManifest }; - self.send_project_status(state, workspace_count, errors.to_vec(), None); + self.send_project_status( + state, + workspace_count, + errors.to_vec(), + None, + fusesoc_core_selections, + ); } fn send_project_status( @@ -39,6 +55,7 @@ impl GlobalState { workspace_count: usize, errors: Vec, message: Option, + fusesoc_core_selections: Vec, ) { let mut manifest_uris = Vec::new(); let mut unconfigured_root_uris = Vec::new(); @@ -50,6 +67,16 @@ impl GlobalState { manifest_uris.push(uri); } } + ProjectManifest::FuseSocCore(path) => { + if let Some(uri) = url_from_path(path.as_path()) { + manifest_uris.push(uri); + } + } + ProjectManifest::FuseSocCoreDir(path) => { + if let Some(uri) = url_from_path(path.as_path()) { + manifest_uris.push(uri); + } + } ProjectManifest::UnconfiguredRoot(path) => { if let Some(uri) = url_from_path(path.as_path()) { unconfigured_root_uris.push(uri); @@ -65,8 +92,61 @@ impl GlobalState { workspace_count, errors, message, + fusesoc_core_selections, }); } + + fn fusesoc_core_selections(&self) -> Vec { + self.config_state + .config + .project_manifests + .iter() + .filter_map(|manifest| match manifest { + ProjectManifest::FuseSocCoreDir(workspace_root) => selection_for_core_paths( + workspace_root, + fusesoc_core_candidates(workspace_root), + ), + ProjectManifest::FuseSocCore(core_path) => { + let workspace_root = core_path.parent()?.to_path_buf(); + selection_for_core_paths(&workspace_root, vec![core_path.clone()]) + } + ProjectManifest::Toml(manifest_path) => { + let toml = match TomlWorkspace::load_from_file(manifest_path) { + Ok(toml) => toml, + Err(error) => { + tracing::warn!( + manifest_path = %manifest_path, + error = %error, + "failed to inspect FuseSoC selection in vide.toml" + ); + return None; + } + }; + let fusesoc = toml.fusesoc?; + if fusesoc.target.is_some() { + return None; + } + let core_path = toml.workspace_root.join(fusesoc.core); + std::fs::metadata(core_path.as_path()) + .is_ok_and(|metadata| metadata.is_file()) + .then(|| selection_for_core_paths(&toml.workspace_root, vec![core_path])) + .flatten() + } + ProjectManifest::UnconfiguredRoot(_) => None, + }) + .filter(|selection| !selection.core_uris.is_empty()) + .collect() + } +} + +fn selection_for_core_paths( + workspace_root: &utils::paths::AbsPathBuf, + core_paths: Vec, +) -> Option { + let workspace_uri = url_from_path(workspace_root.as_path())?; + let core_uris = + core_paths.into_iter().filter_map(|path| url_from_path(path.as_path())).collect(); + Some(FuseSocCoreSelection { workspace_uri, core_uris }) } fn url_from_path(path: &AbsPath) -> Option { @@ -75,6 +155,8 @@ fn url_from_path(path: &AbsPath) -> Option { #[cfg(test)] mod tests { + use std::fs; + use lsp_server::{Connection, Message, Notification as LspNotification}; use lsp_types::notification::Notification as _; use project_model::project_manifest::MANIFEST_FILE_NAME; @@ -153,5 +235,21 @@ mod tests { assert!(matches!(status.state, ProjectStatusState::Loaded)); assert_eq!(status.manifest_uris.len(), 1); assert!(status.unconfigured_root_uris.is_empty()); + assert!(status.fusesoc_core_selections.is_empty()); + } + + #[test] + fn project_status_reports_fusesoc_core_candidates() { + let dir = TestDir::new("project-status-fusesoc-selection"); + fs::write(dir.join("a.core"), "CAPI=2:\nname: v:l:a:1.0\n").unwrap(); + fs::write(dir.join("b.core"), "CAPI=2:\nname: v:l:b:1.0\n").unwrap(); + let (state, client) = test_state_with_root(dir.path().to_path_buf()); + + state.send_project_status_for_result(0, &["multiple FuseSoC cores".to_owned()]); + + let status = project_status_notification(&client); + assert!(matches!(status.state, ProjectStatusState::SelectionRequired)); + assert_eq!(status.fusesoc_core_selections.len(), 1); + assert_eq!(status.fusesoc_core_selections[0].core_uris.len(), 2); } } diff --git a/src/global_state/qihe.rs b/src/global_state/qihe.rs index 0cea53218..67ae05983 100644 --- a/src/global_state/qihe.rs +++ b/src/global_state/qihe.rs @@ -813,7 +813,9 @@ fn qihe_project_manifest_file_name( fn project_manifest_workspace_root(manifest: &ProjectManifest) -> Option<&AbsPath> { match manifest { - ProjectManifest::Toml(path) => path.parent(), + ProjectManifest::Toml(path) + | ProjectManifest::FuseSocCore(path) + | ProjectManifest::FuseSocCoreDir(path) => path.parent(), ProjectManifest::UnconfiguredRoot(path) => Some(path.as_path()), } } diff --git a/src/global_state/reload.rs b/src/global_state/reload.rs index 5c5b3dff0..767932fbf 100644 --- a/src/global_state/reload.rs +++ b/src/global_state/reload.rs @@ -237,6 +237,11 @@ impl GlobalState { .iter() .map(move |file_name| client_watch_glob(root, file_name)) }) + .chain(self.config_state.config.workspace_roots.iter().flat_map(|root| { + project_manifest::FUSESOC_CORE_EXTENSIONS + .iter() + .map(move |ext| client_watch_glob(root, &format!("*.{ext}"))) + })) .collect_vec(); globs.extend( self.workspace @@ -345,6 +350,9 @@ pub(crate) fn should_refresh_for_change(path: &AbsPath, has_structure_change: bo if project_manifest::is_manifest_file_name(file_name) { return true; } + if file_name.ends_with(".core") { + return true; + } if !has_structure_change { return false; diff --git a/src/i18n.rs b/src/i18n.rs index 758d6e0e4..9879a753d 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -71,6 +71,9 @@ pub(crate) mod keys { pub(crate) const CODE_LENS_INSTANCES_ONE: &str = "code_lens.instances_one"; pub(crate) const CODE_LENS_INSTANCES_MANY: &str = "code_lens.instances_many"; + pub(crate) const CODE_LENS_FUSESOC_USE_TARGET: &str = "code_lens.fusesoc_use_target"; + pub(crate) const CODE_LENS_FUSESOC_CONFIGURE_PROJECT: &str = + "code_lens.fusesoc_configure_project"; pub(crate) const CODE_ACTION_ADD_MISSING_CONNECTIONS: &str = "code_action.add_missing_connections"; diff --git a/src/i18n/en.toml b/src/i18n/en.toml index 00deba0eb..8885c252e 100644 --- a/src/i18n/en.toml +++ b/src/i18n/en.toml @@ -39,6 +39,8 @@ unsupported_syntax = "unsupported syntax '{syntax_kind}': {message}" [code_lens] instances_one = "{count} instance" instances_many = "{count} instances" +fusesoc_use_target = "Use target '{target}' for Vide" +fusesoc_configure_project = "Configure FuseSoC project" [code_action] add_missing_connections = "Fill connections" diff --git a/src/i18n/zh-CN.toml b/src/i18n/zh-CN.toml index 00ed547f6..f2d217c7f 100644 --- a/src/i18n/zh-CN.toml +++ b/src/i18n/zh-CN.toml @@ -39,6 +39,8 @@ unsupported_syntax = "暂不支持的语法 '{syntax_kind}':{message}" [code_lens] instances_one = "{count} 个实例" instances_many = "{count} 个实例" +fusesoc_use_target = "将 target '{target}' 用作 Vide 项目" +fusesoc_configure_project = "配置 FuseSoC 项目" [code_action] add_missing_connections = "补全连接" diff --git a/src/lsp_ext/ext.rs b/src/lsp_ext/ext.rs index f5f40dd3c..d7511f080 100644 --- a/src/lsp_ext/ext.rs +++ b/src/lsp_ext/ext.rs @@ -143,6 +143,9 @@ pub enum CodeActionResolveError { pub const RUN_QIHE_ANALYSIS_COMMAND: &str = "vide.server.runQiheAnalysis"; pub const RELOAD_WORKSPACE_COMMAND: &str = "vide.server.reloadWorkspace"; +pub const SELECT_FUSESOC_PROJECT_COMMAND: &str = "vide.server.selectFuseSocProject"; +pub const LIST_FUSESOC_TARGETS_COMMAND: &str = "vide.server.listFuseSocTargets"; +pub const SELECT_FUSESOC_PROJECT_CLIENT_COMMAND: &str = "vide.selectFuseSocProject"; pub const RENAME_EXPANSION_INFO_COMMAND: &str = "vide.server.renameExpansionInfo"; pub const EXPANDED_RENAME_COMMAND: &str = "vide.server.expandedRename"; pub const RENAME_CONFLICT_INFO_COMMAND: &str = "vide.server.renameConflictInfo"; @@ -226,6 +229,7 @@ impl Notification for QiheLogNotification { pub enum ProjectStatusState { Loading, Loaded, + SelectionRequired, #[serde(rename = "none")] NoManifest, Error, @@ -241,6 +245,31 @@ pub struct ProjectStatusParams { pub errors: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub message: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub fusesoc_core_selections: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FuseSocCoreSelection { + pub workspace_uri: lsp_types::Url, + pub core_uris: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SelectFuseSocProjectParams { + pub workspace_uri: lsp_types::Url, + pub core_uri: lsp_types::Url, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListFuseSocTargetsParams { + pub workspace_uri: lsp_types::Url, + pub core_uri: lsp_types::Url, } pub enum ProjectStatusNotification {} diff --git a/src/snapshots/vide__i18n__tests__i18n_matrix.snap b/src/snapshots/vide__i18n__tests__i18n_matrix.snap index 03b0e325c..9e16fd986 100644 --- a/src/snapshots/vide__i18n__tests__i18n_matrix.snap +++ b/src/snapshots/vide__i18n__tests__i18n_matrix.snap @@ -1,5 +1,6 @@ --- source: src/i18n.rs +assertion_line: 249 expression: report --- locale mapping: @@ -13,7 +14,7 @@ message lookup: formatting: Qihe 分析完成,共 3 条诊断。 locale table keys: - en: 81 - zh-CN: 81 + en: 83 + zh-CN: 83 only en: [] only zh-CN: [] diff --git a/src/tests/navigation.rs b/src/tests/navigation.rs index 19935e2b0..fac78e7b2 100644 --- a/src/tests/navigation.rs +++ b/src/tests/navigation.rs @@ -1,5 +1,38 @@ use super::*; +#[test] +fn fusesoc_core_code_lenses_select_core_and_target_together() { + let temp_dir = TempDir::new("fusesoc-code-lenses"); + let core_text = "CAPI=2:\nname: v:l:top:1.0\n\nfilesets:\n rtl:\n files: [top.sv]\n file_type: systemVerilogSource\n\ntargets:\n default:\n filesets: [rtl]\n lint:\n default_tool: verilator\n filesets: [rtl]\n toplevel: top\n"; + fs::write(temp_dir.path().join("top.core"), core_text).unwrap(); + fs::write(temp_dir.path().join("top.sv"), "module top; endmodule\n").unwrap(); + fs::write( + temp_dir.path().join("vide.toml"), + "[fusesoc]\ncore = \"top.core\"\ntarget = \"lint\"\n", + ) + .unwrap(); + + let root_path = temp_dir.path().to_path_buf(); + let (client, server_thread) = + spawn_test_workspace(root_path, ClientCapabilities::default(), UserConfig::default()); + let core_uri = to_proto::url_from_abs_path(temp_dir.path().join("top.core").as_path()).unwrap(); + open_test_document(&client, core_uri.clone(), core_text); + + let lenses = request_code_lenses(&client, core_uri, 1); + let titles = lenses + .iter() + .filter_map(|lens| lens.command.as_ref().map(|command| command.title.clone())) + .collect::>(); + + assert_eq!( + titles, + vec!["Use target 'default' for Vide".to_owned(), "Use target 'lint' for Vide".to_owned(),] + ); + assert!(lenses.iter().all(|lens| lens.data.is_none())); + + shutdown_test_server(&client, server_thread); +} + #[test] fn system_call_inlay_hints_annotate_arguments() { let text = "module m;\ninitial begin\n $display(\"x=%d\", x);\n $readmemh(\"mem.hex\", mem);\nend\nendmodule\n";