diff --git a/crates/cli/src/go_cli.rs b/crates/cli/src/go_cli.rs index a4320b5d..d55dff8c 100644 --- a/crates/cli/src/go_cli.rs +++ b/crates/cli/src/go_cli.rs @@ -43,6 +43,10 @@ pub fn require_go() -> Result<(), i32> { } } +pub fn is_go_present() -> bool { + !matches!(go_status(), GoStatus::Absent) +} + pub fn go_mod_version() -> String { let parts: Vec<&str> = GO_VERSION.split('.').collect(); format!( diff --git a/crates/cli/src/handlers/add.rs b/crates/cli/src/handlers/add.rs index 43bb915b..50adfe52 100644 --- a/crates/cli/src/handlers/add.rs +++ b/crates/cli/src/handlers/add.rs @@ -3,25 +3,33 @@ use std::fs::File; use std::path::{Path, PathBuf}; use crate::go_cli; -use crate::lock::acquire_mutation_lock; +use crate::lock::{acquire_mutation_lock, acquire_target_lock}; use crate::output::{print_add_success, print_preview_notice, print_progress, print_warning}; use crate::workspace::GoWorkspace; use crate::{cli_error, error}; use deps::{GoModule, remove_go_dep, resolve_empty_via, trim_dead_via_parents, upsert_go_dep}; use stdlib::Target; +/// CLI-input dependency: the path the user typed, which may be a subpackage. struct ParsedDependency { - module_path: String, + requested_package: String, version: String, } +/// `ParsedDependency` after `setup_project` has resolved its containing module. +struct ResolvedDependency { + requested_package: String, + canonical_module: String, +} + struct ProjectContext { project_root: PathBuf, target_dir: PathBuf, manifest: deps::Manifest, typedef_cache_dir: PathBuf, resolved_version: String, - _lock: File, + _mutation_lock: File, + _target_lock: File, } struct GraphResult { @@ -31,6 +39,10 @@ struct GraphResult { /// For each reconciled module, the third-party modules it imports /// via its typedefs, e.g. `{ "mux" → ["context"] }`. edges: HashMap>, + /// Modules whose `find_third_party_modules` result is recorded in + /// `edges`. Cache-walk inserts go in `versions` only; the post-walk + /// expansion pass catches them up before manifest application. + expanded: HashSet, } impl GraphResult { @@ -59,7 +71,7 @@ pub fn add(dep_string: &str) -> i32 { return code; } - let dep = match parse_dep_string(dep_string) { + let parsed_dep = match parse_dep_string(dep_string) { Ok(dep) => dep, Err(msg) => { cli_error!( @@ -71,8 +83,8 @@ pub fn add(dep_string: &str) -> i32 { } }; - let project_ctx = match setup_project(&dep) { - Ok(v) => v, + let (project_ctx, resolved_dep) = match setup_project(parsed_dep) { + Ok(pair) => pair, Err(code) => return code, }; @@ -82,20 +94,37 @@ pub fn add(dep_string: &str) -> i32 { Target::host(), ); - let module_graph = match reconcile_module_graph(&dep, &workspace) { + let mut module_graph = match reconcile_module_graph(&resolved_dep, &workspace) { Ok(v) => v, Err(code) => return code, }; - let upgraded = - match apply_graph_to_manifest(&dep.module_path, &project_ctx, &workspace, &module_graph) { - Ok(u) => u, - Err(code) => return code, - }; + let bindgenned = match walk_typedef_cache(&resolved_dep, &workspace, &mut module_graph) { + Ok(v) => v, + Err(code) => return code, + }; + + if let Err(code) = expand_unwalked_modules(&workspace, &mut module_graph) { + return code; + } + + // Expansion above may MVS-upgrade modules whose typedefs the cache walk + // already wrote at the old version, so refresh them at the new pin. + rebuild_drifted_cache_entries(&workspace, &module_graph, &bindgenned); + + let upgraded = match apply_graph_to_manifest( + &resolved_dep.canonical_module, + &project_ctx, + &workspace, + &module_graph, + ) { + Ok(u) => u, + Err(code) => return code, + }; let dep_version = module_graph .versions - .get(&dep.module_path) + .get(&resolved_dep.canonical_module) .cloned() .unwrap_or(project_ctx.resolved_version); @@ -111,7 +140,7 @@ pub fn add(dep_string: &str) -> i32 { .collect(); print_add_success( - &dep.module_path, + &resolved_dep.canonical_module, &dep_version, &module_graph.edges, &module_graph.versions, @@ -221,7 +250,7 @@ fn parse_dep_string(input: &str) -> Result { )); } - let module_path = if deps::is_third_party(path) { + let requested_package = if deps::is_third_party(path) { path.to_string() } else if path.contains('/') { format!("github.com/{}", path) @@ -232,7 +261,7 @@ fn parse_dep_string(input: &str) -> Result { )); }; - if module_path == PRELUDE_MODULE { + if requested_package == PRELUDE_MODULE { return Err( "the Lisette prelude is built into every project and cannot be added as a dependency" .to_string(), @@ -252,7 +281,7 @@ fn parse_dep_string(input: &str) -> Result { }; Ok(ParsedDependency { - module_path, + requested_package, version, }) } @@ -372,7 +401,9 @@ pub(crate) fn find_project_root() -> Option { } } -fn setup_project(dep: &ParsedDependency) -> Result { +fn setup_project( + parsed_dep: ParsedDependency, +) -> Result<(ProjectContext, ResolvedDependency), i32> { let project_root = match find_project_root() { Some(root) => root, None => { @@ -439,7 +470,8 @@ fn setup_project(dep: &ParsedDependency) -> Result { return Err(1); } - let lock = acquire_mutation_lock(&project_target_dir)?; + let mutation_lock = acquire_mutation_lock(&project_target_dir)?; + let target_lock = acquire_target_lock(&project_target_dir)?; let locator = deps::TypedefLocator::new( manifest.go_deps(), @@ -456,69 +488,76 @@ fn setup_project(dep: &ParsedDependency) -> Result { let workspace = GoWorkspace::new(&project_target_dir, &typedef_cache_dir, Target::host()); - let dep_version = if dep.version == "latest" { - print_progress(&format!("Resolving {}@latest", dep.module_path)); - match workspace.query_latest_version(&dep.module_path) { - Ok(v) => v, - Err(msg) => { - let enriched = enrich_with_parent_hint(&workspace, &dep.module_path, msg); - error!("failed to resolve latest version", enriched); - return Err(1); - } - } - } else { - dep.version.clone() - }; - - print_progress(&format!("Fetching {}@{}", dep.module_path, dep_version)); + print_progress(&format!( + "Fetching {}@{}", + parsed_dep.requested_package, parsed_dep.version + )); + // `go get` accepts subpackage paths; `go list -m -json X@latest` does not. if let Err(msg) = workspace.go_get(GoModule { - path: &dep.module_path, - version: &dep_version, + path: &parsed_dep.requested_package, + version: &parsed_dep.version, }) { - let enriched = enrich_with_parent_hint(&workspace, &dep.module_path, msg); + let enriched = enrich_with_parent_hint(&workspace, &parsed_dep.requested_package, msg); error!("failed to download dependency", enriched); return Err(1); } - Ok(ProjectContext { + let info = match workspace.find_containing_module(&parsed_dep.requested_package) { + Ok(info) if !info.path.is_empty() && !info.version.is_empty() => info, + Ok(_) => { + error!( + "failed to resolve containing module", + format!( + "could not resolve containing module for `{}`", + parsed_dep.requested_package + ) + ); + return Err(1); + } + Err(msg) => { + error!("failed to resolve containing module", msg); + return Err(1); + } + }; + + let resolved = ResolvedDependency { + requested_package: parsed_dep.requested_package, + canonical_module: info.path, + }; + + let ctx = ProjectContext { project_root, target_dir: project_target_dir, manifest, typedef_cache_dir, - resolved_version: dep_version, - _lock: lock, - }) + resolved_version: info.version, + _mutation_lock: mutation_lock, + _target_lock: target_lock, + }; + + Ok((ctx, resolved)) } -/// Walk the dependency tree reachable from `dep` and cache typedefs for every -/// module at its final MVS-selected version. -/// -/// BFS-discovers modules by scanning each reconciled module's typedefs for -/// `import "go:..."` references. Because Go's MVS can upgrade an -/// already-reconciled module when a later `go get` raises its version, a drift -/// fixup pass re-queries every reconciled module after the BFS drains and -/// re-enqueues any that shifted. MVS only moves upward, so this converges. -/// -/// Example: `lis add gorilla/mux` reconciles `mux`, finds it imports -/// `gorilla/context`, reconciles `context`. Returns: -/// -/// ```text -/// module_versions: { mux → v1.8.1, context → v1.1.1 } -/// edges: { mux → [context], context → [] } -/// ``` +/// Manifest walk: BFS the third-party module subgraph from `dep.canonical_module` +/// via `go list -json M/...`. Module-grained so the manifest declares every +/// module a future subpackage import could reach; the outer loop converges +/// MVS drift since MVS only moves upward. fn reconcile_module_graph( - dep: &ParsedDependency, + dep: &ResolvedDependency, workspace: &GoWorkspace, ) -> Result { + let canonical_module = dep.canonical_module.as_str(); + let mut module_versions: HashMap = HashMap::new(); let mut edges: HashMap> = HashMap::new(); + let mut expanded: HashSet = HashSet::new(); let mut failed_transitives: HashSet = HashSet::new(); - let mut queue: Vec = vec![dep.module_path.clone()]; + let mut queue: Vec = vec![canonical_module.to_string()]; loop { while let Some(module_path) = queue.pop() { - let is_explicit = module_path == dep.module_path; + let is_explicit = module_path == canonical_module; let module_version = match workspace.query_version(&module_path) { Ok(v) => v, @@ -545,16 +584,11 @@ fn reconcile_module_graph( print_progress(&format!("Resolving transitive dep {}", module_path)); } - let module = GoModule { - path: &module_path, - version: &module_version, - }; - - let packages = match workspace.reconcile(module) { - Ok(p) => p, + let listed = match workspace.find_third_party_modules(&module_path) { + Ok(l) => l, Err(msg) => { if is_explicit { - error!("failed to reconcile dependency", msg); + error!("failed to scan transitive modules", msg); return Err(1); } if failed_transitives.insert(module_path.clone()) { @@ -564,48 +598,51 @@ fn reconcile_module_graph( } }; - let dep_modules = match workspace.find_third_party_deps(module, &packages) { - Ok(t) => t, - Err(msg) => { - if is_explicit { - error!("failed to scan transitive imports", msg); - return Err(1); - } - if failed_transitives.insert(module_path.clone()) { - print_warning(&format!("skipping transitive {}: {}", module_path, msg)); - } - continue; - } - }; + if !listed.package_errors.is_empty() && is_explicit { + let combined: String = listed + .package_errors + .iter() + .map(|e| format!("\n · {}: {}", e.package, e.message)) + .collect(); + error!( + "could not load all packages of dependency", + format!( + "`go list` reported errors in `{}`:{}", + module_path, combined + ) + ); + return Err(1); + } + for err in &listed.package_errors { + print_warning(&format!( + "{}: package error in `{}`: {}", + module_path, err.package, err.message + )); + } module_versions.insert(module_path.clone(), module_version); - edges.insert(module_path, dep_modules.clone()); + edges.insert(module_path.clone(), listed.modules.clone()); + expanded.insert(module_path); - for dep_module in dep_modules { - queue.push(dep_module); + for next in listed.modules { + queue.push(next); } } - // Check if MVS upgraded any module since it was reconciled - let mut more_work = false; - let snapshot: Vec<_> = module_versions - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); - for (module, reconciled_version) in snapshot { - let current_version = workspace.query_version(&module).map_err(|msg| { - error!("failed to resolve module version", msg); - 1 - })?; - if current_version != reconciled_version { - queue.push(module); - more_work = true; - } + let drift = detect_mvs_drift(workspace, &module_versions); + if let Some((module, msg)) = drift.errors.first() { + error!( + "failed to resolve module version", + format!("{}: {}", module, msg) + ); + return Err(1); } - - if !more_work { + if drift.upgraded.is_empty() { break; } + for (module, _) in drift.upgraded { + queue.push(module); + } } if !failed_transitives.is_empty() { @@ -618,9 +655,354 @@ fn reconcile_module_graph( Ok(GraphResult { versions: module_versions, edges, + expanded, }) } +/// Cache walk: bindgen the requested package, then recurse into each +/// typedef's own `go:` imports. Sibling subpackages stay cache misses for +/// the locator to handle on first access. Returns each bindgenned +/// `(module, version, package)` so any later MVS drift in +/// `expand_unwalked_modules` can re-reconcile at the new pin. +fn walk_typedef_cache( + dep: &ResolvedDependency, + workspace: &GoWorkspace, + module_graph: &mut GraphResult, +) -> Result, i32> { + let mut visited: HashSet<(String, String)> = HashSet::new(); + let mut queue: Vec<(String, String, String)> = Vec::new(); + let mut bindgenned: Vec = Vec::new(); + + let seed_packages = seed_cache_walk( + &dep.canonical_module, + &dep.requested_package, + workspace, + &mut queue, + )?; + + while let Some((module_path, version, package_path)) = queue.pop() { + if !visited.insert((module_path.clone(), package_path.clone())) { + continue; + } + + let is_seed = seed_packages.contains(&(module_path.clone(), package_path.clone())); + let module = GoModule { + path: &module_path, + version: &version, + }; + + match workspace.reconcile_package(module, &package_path) { + Ok(stubs) => { + warn_stubbed(&stubs); + bindgenned.push(BindgennedPackage { + module: module_path.clone(), + version: version.clone(), + package: package_path.clone(), + }); + } + Err(msg) => { + if is_seed { + error!("failed to bindgen package", msg); + return Err(1); + } + print_warning(&format!("skipping transitive {}: {}", package_path, msg)); + continue; + } + } + + let imports = match workspace.imports_of(module, &package_path) { + Ok(i) => i, + Err(msg) => { + print_warning(&format!( + "skipping import-walk for {}: {}", + package_path, msg + )); + continue; + } + }; + + for import in imports { + if deps::is_stdlib(&import) { + continue; + } + let containing = match workspace.find_containing_module(&import) { + Ok(info) if !info.path.is_empty() => info, + _ => { + print_warning(&format!( + "could not resolve containing module for `{}` (referenced by {})", + import, package_path + )); + continue; + } + }; + if containing.path == module_path { + let key = (containing.path, import); + if !visited.contains(&key) { + queue.push((key.0, version.clone(), key.1)); + } + continue; + } + + // Record cache-walk-discovered modules so the manifest declares + // every module whose typedef ends up in the cache. + let next_version = if let Some(v) = module_graph.versions.get(&containing.path) { + v.clone() + } else { + let resolved = if !containing.version.is_empty() { + containing.version + } else { + match workspace.query_version(&containing.path) { + Ok(v) => v, + Err(msg) => { + print_warning(&format!("skipping transitive {}: {}", import, msg)); + continue; + } + } + }; + module_graph + .versions + .insert(containing.path.clone(), resolved.clone()); + module_graph + .edges + .entry(containing.path.clone()) + .or_default(); + resolved + }; + + let parent_edges = module_graph.edges.entry(module_path.clone()).or_default(); + if !parent_edges.contains(&containing.path) { + parent_edges.push(containing.path.clone()); + } + + let key = (containing.path.clone(), import.clone()); + if visited.contains(&key) { + continue; + } + queue.push((containing.path, next_version, import)); + } + } + + Ok(bindgenned) +} + +struct BindgennedPackage { + module: String, + version: String, + package: String, +} + +/// Re-reconcile cache entries whose module version was raised by MVS drift. +fn rebuild_drifted_cache_entries( + workspace: &GoWorkspace, + graph: &GraphResult, + bindgenned: &[BindgennedPackage], +) { + for entry in bindgenned { + let Some(current) = graph.versions.get(&entry.module) else { + continue; + }; + if current == &entry.version { + continue; + } + let module = GoModule { + path: &entry.module, + version: current, + }; + match workspace.reconcile_package(module, &entry.package) { + Ok(stubs) => warn_stubbed(&stubs), + Err(msg) => { + print_warning(&format!( + "could not re-bindgen `{}` after MVS drift to {}: {}", + entry.package, current, msg + )); + } + } + } +} + +fn warn_stubbed(stubs: &[String]) { + for stubbed in stubs { + print_warning(&format!( + "{}: type-check failed; emitted as unloadable stub", + stubbed + )); + } +} + +/// Run the manifest walk for modules in `graph.versions` whose +/// `find_third_party_modules` result is missing, until the graph is closed +/// under MVS drift. Failures are warnings since these are all transitives. +fn expand_unwalked_modules(workspace: &GoWorkspace, graph: &mut GraphResult) -> Result<(), i32> { + let mut failed: HashSet = HashSet::new(); + + let mut queue: Vec = graph + .versions + .keys() + .filter(|m| !graph.expanded.contains(*m)) + .cloned() + .collect(); + + loop { + while let Some(module_path) = queue.pop() { + if graph.expanded.contains(&module_path) { + continue; + } + + if !graph.versions.contains_key(&module_path) { + match workspace.query_version(&module_path) { + Ok(v) => { + graph.versions.insert(module_path.clone(), v); + } + Err(msg) => { + if failed.insert(module_path.clone()) { + print_warning(&format!("skipping transitive {}: {}", module_path, msg)); + } + continue; + } + } + } + + let listed = match workspace.find_third_party_modules(&module_path) { + Ok(l) => l, + Err(msg) => { + if failed.insert(module_path.clone()) { + print_warning(&format!("skipping transitive {}: {}", module_path, msg)); + } + continue; + } + }; + + for err in &listed.package_errors { + print_warning(&format!( + "{}: package error in `{}`: {}", + module_path, err.package, err.message + )); + } + + let entry = graph.edges.entry(module_path.clone()).or_default(); + for next in &listed.modules { + if !entry.contains(next) { + entry.push(next.clone()); + } + } + graph.expanded.insert(module_path); + + for next in listed.modules { + if !graph.expanded.contains(&next) { + queue.push(next); + } + } + } + + let drift = detect_mvs_drift(workspace, &graph.versions); + for (module, msg) in drift.errors { + if failed.insert(module.clone()) { + print_warning(&format!( + "could not re-query version for {}: {}", + module, msg + )); + } + } + + if drift.upgraded.is_empty() { + break; + } + + // Drifted module's outgoing edges may have changed; parent edges + // pointing at it still stand (parent still imports it). + for (module, new_version) in drift.upgraded { + graph.versions.insert(module.clone(), new_version); + graph.expanded.remove(&module); + graph.edges.remove(&module); + queue.push(module); + } + } + + Ok(()) +} + +/// Seed the cache walk's queue. Falls back to enumerating subpackages when +/// the requested module has no root package (e.g. `golang.org/x/sync`). +fn seed_cache_walk( + canonical_module: &str, + requested_package: &str, + workspace: &GoWorkspace, + queue: &mut Vec<(String, String, String)>, +) -> Result, i32> { + let version = match workspace.query_version(canonical_module) { + Ok(v) => v, + Err(msg) => { + error!("failed to resolve module version", msg); + return Err(1); + } + }; + + let push_seed = |queue: &mut Vec<_>, seeds: &mut HashSet<_>, package: String| { + seeds.insert((canonical_module.to_string(), package.clone())); + queue.push((canonical_module.to_string(), version.clone(), package)); + }; + + let mut seeds: HashSet<(String, String)> = HashSet::new(); + + if canonical_module != requested_package { + push_seed(queue, &mut seeds, requested_package.to_string()); + return Ok(seeds); + } + + let packages = match workspace.list_packages(canonical_module) { + Ok(p) => p, + Err(msg) => { + error!("failed to list packages", msg); + return Err(1); + } + }; + + if packages.iter().any(|p| p == canonical_module) { + push_seed(queue, &mut seeds, canonical_module.to_string()); + return Ok(seeds); + } + + if packages.is_empty() { + cli_error!( + "Cannot bindgen module", + format!("module `{}` has no importable packages", canonical_module), + "Check the module path and try a specific subpackage like `lis add /`" + ); + return Err(1); + } + + for pkg in packages { + push_seed(queue, &mut seeds, pkg); + } + Ok(seeds) +} + +#[derive(Default)] +struct DriftReport { + /// `(module, new_version)` pairs whose pin moved. + upgraded: Vec<(String, String)>, + /// `(module, error)` pairs we could not re-query. + errors: Vec<(String, String)>, +} + +/// Snapshot every recorded module's pin and return the diff against Go's +/// current state. +fn detect_mvs_drift(workspace: &GoWorkspace, versions: &HashMap) -> DriftReport { + let mut report = DriftReport::default(); + let snapshot: Vec<(String, String)> = versions + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + for (module, recorded) in snapshot { + match workspace.query_version(&module) { + Ok(current) if current != recorded => report.upgraded.push((module, current)), + Ok(_) => {} + Err(msg) => report.errors.push((module, msg)), + } + } + report +} + struct DirectUpgrade { path: String, old_version: String, @@ -657,7 +1039,7 @@ fn apply_graph_to_manifest( .versions .get(added_dep) .map(|v| v.as_str()) - .unwrap_or(""); + .unwrap_or(&ctx.resolved_version); let mut upgraded: Vec = Vec::new(); if let Err(msg) = upsert_go_dep(project_root, added_dep, added_dep_version, None) { diff --git a/crates/cli/src/handlers/build.rs b/crates/cli/src/handlers/build.rs index c513d552..e2aeaacf 100644 --- a/crates/cli/src/handlers/build.rs +++ b/crates/cli/src/handlers/build.rs @@ -1,26 +1,38 @@ use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; +use std::sync::Arc; use std::time::Instant; use crate::cli_error; use crate::go_cli; -use crate::typedef_regen::generate_missing_typedefs; +use crate::lock::acquire_target_lock; +use crate::workspace::WorkspaceBindgen; use diagnostics::render::{self, Filter}; use lisette::fs::{LocalFileSystem, prune_orphan_go_files}; use lisette::pipeline::{CompileConfig, CompilePhase, compile}; pub fn build(path: Option, debug: bool, quiet: bool) -> i32 { - if let Err(code) = crate::go_cli::require_go() { - return code; - } - - let start = Instant::now(); - let project_root = path.unwrap_or_else(|| ".".to_string()); let project_path = Path::new(&project_root); + let prep = match prepare_project_build(project_path) { + Ok(p) => p, + Err(code) => return code, + }; + + let _target_lock = match acquire_target_lock(&prep.target_dir) { + Ok(f) => f, + Err(code) => return code, + }; + + build_locked(&prep, debug, quiet) +} + +pub(super) fn prepare_project_build(project_path: &Path) -> Result { + crate::go_cli::require_go()?; + if !validate_project(project_path) { - return 1; + return Err(1); } let (manifest, locator) = match deps::TypedefLocator::from_project_with_manifest(project_path) { @@ -31,17 +43,60 @@ pub fn build(path: Option, debug: bool, quiet: bool) -> i32 { msg, "Run `lis new ` to create a project, or fix `lisette.toml`" ); - return 1; + return Err(1); } }; - if let Err(code) = generate_missing_typedefs(project_path, &manifest) { - return code; + let target_dir = project_path.join("target"); + if let Err(e) = fs::create_dir_all(&target_dir) { + cli_error!( + "Failed to compile Lisette project to Go", + format!("Failed to create `target` directory: {}", e), + "Check directory permissions" + ); + return Err(1); + } + + Ok(BuildPrep { + project_path: project_path.to_path_buf(), + target_dir, + manifest, + locator, + }) +} + +pub(super) struct BuildPrep { + pub project_path: PathBuf, + pub target_dir: PathBuf, + pub manifest: deps::Manifest, + pub locator: deps::TypedefLocator, +} + +pub(super) fn build_locked(prep: &BuildPrep, debug: bool, quiet: bool) -> i32 { + let start = Instant::now(); + + if let Err(e) = + go_cli::write_go_mod(&prep.target_dir, &prep.manifest.project.name, &prep.locator) + { + cli_error!( + "Failed to compile Lisette project to Go", + e, + "Check file permissions on `target/go.mod`" + ); + return 1; } - let main_lis = project_path.join("src/main.lis"); - let go_module_name = &manifest.project.name; - let version = &manifest.project.version; + let typedef_cache_dir = deps::typedef_cache_dir(&prep.project_path); + let bindgen = Arc::new(WorkspaceBindgen::new( + prep.target_dir.clone(), + typedef_cache_dir, + prep.locator.target(), + )); + let locator = prep.locator.clone().with_bindgen(bindgen); + + let main_lis = prep.project_path.join("src/main.lis"); + let go_module_name = &prep.manifest.project.name; + let version = &prep.manifest.project.version; let main_lis_source = match fs::read_to_string(&main_lis) { Ok(s) => s, @@ -84,7 +139,7 @@ pub fn build(path: Option, debug: bool, quiet: bool) -> i32 { standalone_mode: false, load_siblings: true, debug, - project_root: Some(project_path.to_path_buf()), + project_root: Some(prep.project_path.clone()), locator: locator.clone(), }; @@ -118,28 +173,9 @@ pub fn build(path: Option, debug: bool, quiet: bool) -> i32 { return 1; } - let target_dir = project_path.join("target"); - if let Err(e) = fs::create_dir_all(&target_dir) { - cli_error!( - "Failed to compile Lisette project to Go", - format!("Failed to create `target` directory: {}", e), - "Check directory permissions" - ); - return 1; - } - - if let Err(e) = go_cli::write_go_mod(&target_dir, &compile_config.go_module, &locator) { - cli_error!( - "Failed to compile Lisette project to Go", - e, - "Check file permissions" - ); - return 1; - } - let heading = "Failed to compile Lisette project to Go"; - if let Err(code) = go_cli::write_go_outputs(&target_dir, &result.output, heading) { + if let Err(code) = go_cli::write_go_outputs(&prep.target_dir, &result.output, heading) { return code; } @@ -148,7 +184,7 @@ pub fn build(path: Option, debug: bool, quiet: bool) -> i32 { .iter() .map(|file| file.name.as_str()) .collect(); - if let Err(e) = prune_orphan_go_files(&target_dir, &produced) { + if let Err(e) = prune_orphan_go_files(&prep.target_dir, &produced) { cli_error!( "Failed to compile Lisette project to Go", format!("Failed to prune stale Go files: {}", e), @@ -157,7 +193,7 @@ pub fn build(path: Option, debug: bool, quiet: bool) -> i32 { return 1; } - if let Err(code) = go_cli::finalize_go_dir(&target_dir, heading, locator.target()) { + if let Err(code) = go_cli::finalize_go_dir(&prep.target_dir, heading, locator.target()) { return code; } diff --git a/crates/cli/src/handlers/check.rs b/crates/cli/src/handlers/check.rs index c8130143..2dbf6940 100644 --- a/crates/cli/src/handlers/check.rs +++ b/crates/cli/src/handlers/check.rs @@ -1,6 +1,7 @@ use rustc_hash::FxHashMap as HashMap; use std::fs; use std::path::{Path, PathBuf}; +use std::sync::Arc; use std::time::Instant; use deps::TypedefLocator; @@ -9,7 +10,8 @@ use lisette::fs::LocalFileSystem; use lisette::pipeline::{CompileConfig, CompilePhase, CompileResult, compile}; use crate::cli_error; -use crate::typedef_regen::generate_missing_typedefs; +use crate::lock::acquire_target_lock; +use crate::workspace::WorkspaceBindgen; pub fn check(path: Option, errors_only: bool, warnings_only: bool) -> i32 { let target = path.unwrap_or_else(|| ".".to_string()); @@ -70,11 +72,41 @@ fn check_project(project_path: &Path, filter: &Filter) -> i32 { } }; - if let Err(code) = generate_missing_typedefs(project_path, &manifest) { - return code; + let target_dir = project_path.join("target"); + if let Err(e) = fs::create_dir_all(&target_dir) { + cli_error!( + "Failed to check project", + format!("Failed to create target directory: {}", e), + "Check directory permissions" + ); + return 1; } - check_single_file(&src_main, filter, true, locator) + let target_lock = match acquire_target_lock(&target_dir) { + Ok(f) => f, + Err(code) => return code, + }; + + if let Err(e) = crate::go_cli::write_go_mod(&target_dir, &manifest.project.name, &locator) { + cli_error!( + "Failed to check project", + e, + "Check file permissions on `target/go.mod`" + ); + return 1; + } + + let typedef_cache_dir = deps::typedef_cache_dir(project_path); + let bindgen = Arc::new(WorkspaceBindgen::new( + target_dir, + typedef_cache_dir, + locator.target(), + )); + let locator = locator.with_bindgen(bindgen); + + let result = check_single_file(&src_main, filter, true, locator); + drop(target_lock); + result } fn check_single_file( @@ -84,6 +116,7 @@ fn check_single_file( locator: TypedefLocator, ) -> i32 { let start = Instant::now(); + eprintln!(); let Some((result, source, filename)) = compile_single_file(file_path, load_siblings, locator) else { return 1; // Read error already reported by compile_single_file @@ -180,6 +213,7 @@ fn check_loose_dir(dir: &Path, filter: &Filter) -> i32 { let mut read_failures = 0; let start = Instant::now(); + eprintln!(); for dir_files in dirs.values() { let mut compiled = None; diff --git a/crates/cli/src/handlers/lsp.rs b/crates/cli/src/handlers/lsp.rs index c6789864..4d2c2c41 100644 --- a/crates/cli/src/handlers/lsp.rs +++ b/crates/cli/src/handlers/lsp.rs @@ -1,12 +1,19 @@ +use std::sync::Arc; + +use deps::BindgenSetup; use tower_lsp::{LspService, Server}; +use crate::workspace::WorkspaceBindgenSetup; + pub fn lsp() -> i32 { let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); rt.block_on(async { let stdin = tokio::io::stdin(); let stdout = tokio::io::stdout(); - let (service, socket) = LspService::new(lsp::Backend::new); + let setup: Arc = Arc::new(WorkspaceBindgenSetup); + let (service, socket) = + LspService::new(move |client| lsp::Backend::new(client, Some(setup.clone()))); Server::new(stdin, stdout, socket).serve(service).await; }); 0 diff --git a/crates/cli/src/handlers/run.rs b/crates/cli/src/handlers/run.rs index c81b85ac..3179f41c 100644 --- a/crates/cli/src/handlers/run.rs +++ b/crates/cli/src/handlers/run.rs @@ -170,12 +170,26 @@ pub fn run(target: Option, args: Vec, debug: bool) -> i32 { } fn run_project(path: &str, args: Vec, debug: bool) -> i32 { - let build_result = crate::handlers::build(Some(path.to_string()), debug, true); + let project_path = Path::new(path); + + let prep = match super::build::prepare_project_build(project_path) { + Ok(p) => p, + Err(code) => return code, + }; + + // Held across the user program's lifetime so a concurrent `lis check`/ + // `build`/`sync`/LSP cannot rewrite `target/` mid-`go run` compile. + let _target_lock = match crate::lock::acquire_target_lock(&prep.target_dir) { + Ok(f) => f, + Err(code) => return code, + }; + + let target_dir = prep.target_dir.clone(); + let build_result = super::build::build_locked(&prep, debug, true); if build_result != 0 { return build_result; } - let target_dir = Path::new(path).join("target"); run_with_invocation_cwd( &target_dir, &args, diff --git a/crates/cli/src/handlers/sync.rs b/crates/cli/src/handlers/sync.rs index 7920b97d..6e406731 100644 --- a/crates/cli/src/handlers/sync.rs +++ b/crates/cli/src/handlers/sync.rs @@ -1,13 +1,18 @@ use std::path::{Path, PathBuf}; +use std::sync::Arc; -use syntax::ast::Expression; +use stdlib::Target; +use syntax::ast::{Expression, ImportAlias}; use syntax::parse::Parser; use lisette::fs::collect_lis_filepaths_recursive; +use crate::go_cli; use crate::handlers::add::find_project_root; -use crate::lock::acquire_mutation_lock; +use crate::lock::{acquire_mutation_lock, acquire_target_lock}; use crate::output::{print_preview_notice, print_sync_summary}; +use crate::typedef_regen::prewarm_typedef_cache; +use crate::workspace::WorkspaceBindgen; use crate::{cli_error, error}; pub fn sync() -> i32 { @@ -77,12 +82,16 @@ pub fn sync() -> i32 { return 1; } - let _lock = match acquire_mutation_lock(&target_dir) { + let _mutation_lock = match acquire_mutation_lock(&target_dir) { + Ok(f) => f, + Err(code) => return code, + }; + let _target_lock = match acquire_target_lock(&target_dir) { Ok(f) => f, Err(code) => return code, }; - let imported_pkgs = match scan_source_imports(&project_root.join("src")) { + let scanned = match scan_source_imports(&project_root.join("src")) { Ok(pkgs) => pkgs, Err(SourceScanError::Parse { path, message }) => { cli_error!( @@ -101,6 +110,31 @@ pub fn sync() -> i32 { } }; + let mut bindgen_runner: Option> = None; + let prewarm_result = if !scanned.non_blank.is_empty() { + let target = Target::host(); + + let locator = + deps::TypedefLocator::new(manifest.go_deps(), Some(project_root.clone()), target); + if let Err(msg) = go_cli::write_go_mod(&target_dir, &manifest.project.name, &locator) { + error!("failed to write target/go.mod", msg); + return 1; + } + + let typedef_cache_dir = deps::typedef_cache_dir(&project_root); + let runner = Arc::new(WorkspaceBindgen::new( + target_dir.clone(), + typedef_cache_dir, + target, + )); + let locator = locator.with_bindgen(runner.clone()); + bindgen_runner = Some(runner); + + prewarm_typedef_cache(&scanned.non_blank, &locator) + } else { + Ok(()) + }; + let trimmed = match deps::trim_dead_via_parents(&project_root) { Ok(t) => t, Err(msg) => { @@ -109,7 +143,7 @@ pub fn sync() -> i32 { } }; - let report = match deps::resolve_empty_via(&project_root, &imported_pkgs) { + let report = match deps::resolve_empty_via(&project_root, &scanned.all) { Ok(r) => r, Err(msg) => { error!("failed to update manifest", msg); @@ -117,9 +151,12 @@ pub fn sync() -> i32 { } }; - print_sync_summary(&trimmed, &report.promoted, &report.removed); + let needs_separator = bindgen_runner + .as_ref() + .is_some_and(|r| r.progress_emitted()); + print_sync_summary(&trimmed, &report.promoted, &report.removed, needs_separator); - 0 + prewarm_result.err().unwrap_or(0) } enum SourceScanError { @@ -133,12 +170,19 @@ enum SourceScanError { }, } -/// Collect every third-party Go package path imported via `import "go:..."` -/// across `src/**/*.lis`. Aborts on the first parse or read error. -fn scan_source_imports(src_dir: &Path) -> Result, SourceScanError> { - let mut imports = Vec::new(); +struct ScannedImports { + /// All third-party `go:` imports (blank-imports keep modules referenced). + all: Vec, + /// Third-party `go:` imports excluding `_`-aliased blank ones. + non_blank: Vec, +} + +/// Collect every third-party `go:` import across `src/**/*.lis`. +fn scan_source_imports(src_dir: &Path) -> Result { + let mut all = Vec::new(); + let mut non_blank = Vec::new(); if !src_dir.is_dir() { - return Ok(imports); + return Ok(ScannedImports { all, non_blank }); } for path in collect_lis_filepaths_recursive(src_dir) { @@ -154,14 +198,17 @@ fn scan_source_imports(src_dir: &Path) -> Result, SourceScanError> { }); } for expr in &parse_result.ast { - if let Expression::ModuleImport { name, .. } = expr + if let Expression::ModuleImport { name, alias, .. } = expr && let Some(pkg) = name.strip_prefix("go:") && deps::is_third_party(pkg) { - imports.push(pkg.to_string()); + all.push(pkg.to_string()); + if !matches!(alias, Some(ImportAlias::Blank(_))) { + non_blank.push(pkg.to_string()); + } } } } - Ok(imports) + Ok(ScannedImports { all, non_blank }) } diff --git a/crates/cli/src/lock.rs b/crates/cli/src/lock.rs index 57bcb1f6..d032e998 100644 --- a/crates/cli/src/lock.rs +++ b/crates/cli/src/lock.rs @@ -1,22 +1,14 @@ -use std::fs::File; +use std::fs::{self, File}; use std::path::Path; use fs2::FileExt; use crate::{cli_error, error}; +/// Project-scoped, fail-fast lock guarding `lisette.toml` mutations pub fn acquire_mutation_lock(target_dir: &Path) -> Result { let lock_path = target_dir.join(".lis-mutate.lock"); - let file = match File::create(&lock_path) { - Ok(f) => f, - Err(e) => { - error!( - "failed to create lock file", - format!("Failed to create `{}`: {}", lock_path.display(), e) - ); - return Err(1); - } - }; + let file = create_lock_file(&lock_path)?; if let Err(e) = file.try_lock_exclusive() { if e.kind() == std::io::ErrorKind::WouldBlock { @@ -33,3 +25,41 @@ pub fn acquire_mutation_lock(target_dir: &Path) -> Result { Ok(file) } + +/// Project-scoped, blocking lock guarding `target/` mutations and the typedef cache. +pub fn acquire_target_lock(target_dir: &Path) -> Result { + target_lock_inner(target_dir).map_err(|msg| { + error!("failed to acquire target lock", msg); + 1 + }) +} + +/// `acquire_target_lock` variant that returns the error as a `String` +/// for the LSP, which surfaces errors as analysis diagnostics. +pub(crate) fn acquire_target_lock_quiet(target_dir: &Path) -> Result { + target_lock_inner(target_dir) +} + +fn target_lock_inner(target_dir: &Path) -> Result { + let dir = target_dir.join(".lisette"); + fs::create_dir_all(&dir).map_err(|e| format!("Failed to create `{}`: {}", dir.display(), e))?; + + let lock_path = dir.join(".lis-target.lock"); + let file = File::create(&lock_path) + .map_err(|e| format!("Failed to create `{}`: {}", lock_path.display(), e))?; + + file.lock_exclusive() + .map_err(|e| format!("Failed to acquire target lock: {}", e))?; + + Ok(file) +} + +fn create_lock_file(path: &Path) -> Result { + File::create(path).map_err(|e| { + error!( + "failed to create lock file", + format!("Failed to create `{}`: {}", path.display(), e) + ); + 1 + }) +} diff --git a/crates/cli/src/output.rs b/crates/cli/src/output.rs index ec435906..e00bc43d 100644 --- a/crates/cli/src/output.rs +++ b/crates/cli/src/output.rs @@ -303,15 +303,18 @@ fn print_tree_node( } } -pub fn print_sync_summary(trimmed: &[deps::TrimmedVia], promoted: &[String], removed: &[String]) { - eprintln!(); +pub fn print_sync_summary( + trimmed: &[deps::TrimmedVia], + promoted: &[String], + removed: &[String], + leading_blank: bool, +) { + if leading_blank { + eprintln!(); + } if trimmed.is_empty() && promoted.is_empty() && removed.is_empty() { - if use_color() { - eprintln!(" {} Manifest already in sync", "✓".green()); - } else { - eprintln!(" ✓ Manifest already in sync"); - } + eprintln!(" ✓ Manifest already in sync"); return; } diff --git a/crates/cli/src/pipeline.rs b/crates/cli/src/pipeline.rs index aee8c3cd..8ac36d93 100644 --- a/crates/cli/src/pipeline.rs +++ b/crates/cli/src/pipeline.rs @@ -81,7 +81,7 @@ pub fn compile( let user_file_count = semantic_result.files.len(); - let mut sources: HashMap = semantic_result + let sources: HashMap = semantic_result .files .iter() .map(|(file_id, file)| { @@ -95,13 +95,6 @@ pub fn compile( }) .collect(); - for (file_id, typedef) in &semantic_result.typedef_sources { - sources.entry(*file_id).or_insert_with(|| SourceInfo { - source: typedef.source.clone(), - filename: typedef.filename.clone(), - }); - } - let failed = semantic_result.failed(); let mut errors = semantic_result.errors.clone(); let lints = semantic_result.lints.clone(); diff --git a/crates/cli/src/typedef_regen.rs b/crates/cli/src/typedef_regen.rs index 66d91c48..1a21c4f7 100644 --- a/crates/cli/src/typedef_regen.rs +++ b/crates/cli/src/typedef_regen.rs @@ -1,142 +1,83 @@ -use std::fs; -use std::fs::File; -use std::path::Path; - -use fs2::FileExt; - -use crate::go_cli; -use crate::output::{print_progress, print_warning}; -use crate::workspace::GoWorkspace; -use crate::{cli_error, error}; -use deps::{GoModule, Manifest, TypedefLocator}; -use stdlib::Target; - -/// Generate any Go typedefs declared in the manifest but missing from the cache: -/// `/target/.lisette/typedefs/lis@v{version}//{module}@{version}/*.d.lis` -pub fn generate_missing_typedefs(project_root: &Path, manifest: &Manifest) -> Result<(), i32> { - let go_deps = manifest.go_deps(); - if go_deps.is_empty() { - return Ok(()); - } - - let typedef_cache_dir = deps::typedef_cache_dir(project_root); - let target = Target::host(); - - let missing_modules: Vec<(String, String)> = go_deps - .iter() - .filter(|(module_path, dep)| { - !module_dir_populated(&typedef_cache_dir, target, module_path, &dep.version) - }) - .map(|(module_path, dep)| (module_path.clone(), dep.version.clone())) - .collect(); - - if missing_modules.is_empty() { - return Ok(()); - } - - go_cli::require_go()?; - - let project_target_dir = project_root.join("target"); - if project_target_dir.is_file() { - cli_error!( - "Failed to regenerate Go typedefs", - "`target/` exists but is a file, not a directory", - "Remove or move `target/` and retry" - ); - return Err(1); - } - if let Err(e) = fs::create_dir_all(&project_target_dir) { - error!( - "failed to regenerate Go typedefs", - format!("Failed to create target directory: {}", e) - ); - return Err(1); - } - - if let Err(e) = fs::create_dir_all(&typedef_cache_dir) { - error!( - "failed to regenerate Go typedefs", - format!("Failed to create typedef cache directory: {}", e) - ); - return Err(1); - } - - let _lock = acquire_regen_lock(&typedef_cache_dir)?; - - // Another process may have regenerated everything while we were waiting. - let still_missing: Vec<(String, String)> = missing_modules - .into_iter() - .filter(|(module_path, version)| { - !module_dir_populated(&typedef_cache_dir, target, module_path, version) - }) - .collect(); - - if still_missing.is_empty() { - return Ok(()); - } - - let locator = TypedefLocator::new(go_deps, Some(project_root.to_path_buf()), target); - if let Err(msg) = go_cli::write_go_mod(&project_target_dir, &manifest.project.name, &locator) { - error!("failed to write target/go.mod", msg); - return Err(1); +use deps::{BindgenFailure, TypedefLocator, TypedefLocatorResult}; +use rustc_hash::FxHashSet as HashSet; + +use crate::output::print_warning; +use crate::workspace::extract_go_imports; + +/// Generate the typedef for every non-blank `go:` import in source, then +/// recurse into each typedef's own `go:` imports. Returns `Err(1)` if any +/// declared import fails; undeclared and unknown-stdlib imports are left +/// to the type-checker. +pub fn prewarm_typedef_cache( + source_imports: &[String], + locator: &TypedefLocator, +) -> Result<(), i32> { + let mut visited: HashSet = HashSet::default(); + let mut queue: Vec = Vec::with_capacity(source_imports.len()); + for pkg in source_imports { + if visited.insert(pkg.clone()) { + queue.push(pkg.clone()); + } } - let workspace = GoWorkspace::new(&project_target_dir, &typedef_cache_dir, target); - - let lis_version = env!("CARGO_PKG_VERSION"); - print_progress(&format!( - "Regenerating Go typedefs for lis v{}", - lis_version - )); - - for (module_path, version) in &still_missing { - print_progress(&format!("Regenerating {}@{}", module_path, version)); - - let module = GoModule { - path: module_path, - version, - }; - if let Err(msg) = workspace.reconcile(module) { - print_warning(&format!("Failed to regenerate {}: {}", module_path, msg)); + let mut had_failure = false; + + while let Some(pkg) = queue.pop() { + match locator.find_typedef_content(&pkg) { + TypedefLocatorResult::Found { content, .. } => { + for imp in extract_go_imports(&content) { + if visited.insert(imp.clone()) { + queue.push(imp); + } + } + } + TypedefLocatorResult::UnknownStdlib | TypedefLocatorResult::UndeclaredImport => { + // Type-checker handles these. + } + TypedefLocatorResult::MissingTypedef { module, version } => { + print_warning(&format!( + "missing typedef for {}", + pkg_label(&pkg, &module, &version) + )); + had_failure = true; + } + TypedefLocatorResult::UnreadableTypedef { path, error } => { + print_warning(&format!( + "unreadable typedef at `{}`: {}", + path.display(), + error + )); + had_failure = true; + } + TypedefLocatorResult::BindgenFailed { + kind, + module, + version, + .. + } => { + match kind { + BindgenFailure::GoToolchainMissing => { + print_warning(&format!( + "cannot bindgen {}: Go toolchain not installed", + pkg_label(&pkg, &module, &version) + )); + } + BindgenFailure::InvocationFailed { stderr } => { + print_warning(&format!( + "bindgen failed for {}: {}", + pkg_label(&pkg, &module, &version), + stderr.trim() + )); + } + } + had_failure = true; + } } } - Ok(()) + if had_failure { Err(1) } else { Ok(()) } } -fn module_dir_populated( - cache_dir: &Path, - target: Target, - module_path: &str, - version: &str, -) -> bool { - let module_dir = cache_dir - .join(target.cache_segment()) - .join(format!("{}@{}", module_path, version)); - match fs::read_dir(&module_dir) { - Ok(mut entries) => entries.next().is_some(), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => false, - Err(_) => false, - } -} - -fn acquire_regen_lock(typedef_cache_dir: &Path) -> Result { - let lock_path = typedef_cache_dir.join(".lis-regen.lock"); - let file = match File::create(&lock_path) { - Ok(f) => f, - Err(e) => { - error!( - "failed to create regen lock file", - format!("Failed to create `{}`: {}", lock_path.display(), e) - ); - return Err(1); - } - }; - - if let Err(e) = file.lock_exclusive() { - error!("failed to acquire regen lock", format!("{}", e)); - return Err(1); - } - - Ok(file) +fn pkg_label(pkg: &str, module: &str, version: &str) -> String { + format!("`{}` ({} {})", pkg, module, version) } diff --git a/crates/cli/src/workspace.rs b/crates/cli/src/workspace.rs index 52c2079c..1eb334a2 100644 --- a/crates/cli/src/workspace.rs +++ b/crates/cli/src/workspace.rs @@ -1,12 +1,14 @@ use std::collections::HashSet; use std::fs; use std::io::Write; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, OnceLock}; -use deps::{GoModule, GoPackage}; +use deps::{Bindgen, BindgenFailure, BindgenSession, BindgenSetup, GoModule, GoPackage}; use serde::Deserialize; -use syntax::ast::Expression; +use syntax::ast::{Expression, ImportAlias}; use syntax::parse::Parser; const BINDGEN_GO_MODULE: &str = "github.com/ivov/lisette/bindgen"; @@ -264,89 +266,139 @@ impl<'a> GoWorkspace<'a> { .map_err(|e| format!("Bindgen produced unparseable manifest: {}", e)) } - /// Ensure typedefs exist in cache for every public package in a Go module. - /// - /// Returns the list of packages in the module. - pub fn reconcile(&self, module: GoModule) -> Result, String> { + /// Reconcile a single package; returns stubbed packages so callers can warn. + pub fn reconcile_package( + &self, + module: GoModule, + package: &str, + ) -> Result, String> { self.go_get(module)?; - let packages = self.list_packages(module.path)?; - - let uncached: Vec = packages - .iter() - .filter(|pkg_path| { - let pkg = GoPackage { - module, - package: pkg_path, - }; - !pkg.typedef_path(self.typedef_cache_dir, self.target) - .exists() - }) - .cloned() - .collect(); - - if uncached.is_empty() { - return Ok(packages); - } - - let manifest = self.run_bindgen_batch(&uncached)?; - + let manifest = self.run_bindgen_batch(&[package.to_string()])?; let outcome = self.apply_batch_manifest(&manifest, module); - for stubbed in &outcome.stubbed { - crate::output::print_warning(&format!( - "{}: type-check failed; emitted as unloadable stub", - stubbed - )); - } - if !outcome.failures.is_empty() { return Err(outcome.failures.join("\n")); } - Ok(packages) + Ok(outcome.stubbed) } - /// Find the third-party Go modules this module's typedefs depend on. - pub fn find_third_party_deps( - &self, - module: GoModule, - module_packages: &[String], - ) -> Result, String> { - let mut third_party_deps = Vec::new(); - let mut seen: HashSet = HashSet::new(); + /// Return every `go:` import listed in the cached `.d.lis`. + pub fn imports_of(&self, module: GoModule, package: &str) -> Result, String> { + let pkg = GoPackage { module, package }; + let path = pkg.typedef_path(self.typedef_cache_dir, self.target); + let content = fs::read_to_string(&path) + .map_err(|e| format!("Failed to read cached typedef `{}`: {}", path.display(), e))?; + Ok(extract_go_imports(&content)) + } - for pkg_path in module_packages { - let pkg = GoPackage { - module, - package: pkg_path, + /// Return every third-party module any public subpackage of `module_path` + /// imports, plus any non-benign package errors `go list -e -json` reported. + pub fn find_third_party_modules(&self, module_path: &str) -> Result { + let pattern = format!("{}/...", module_path); + let stdout = self.run_go(&["list", "-mod=mod", "-e", "-json", &pattern])?; + + let mut import_set: HashSet = HashSet::new(); + let mut package_errors: Vec = Vec::new(); + + let stream = serde_json::Deserializer::from_str(&stdout).into_iter::(); + for entry in stream { + let value = match entry { + Ok(v) => v, + Err(e) => { + return Err(format!( + "Failed to parse `go list -json {}/...` output: {}", + module_path, e + )); + } }; - let pkg_typedef_path = pkg.typedef_path(self.typedef_cache_dir, self.target); - let typedef = fs::read_to_string(&pkg_typedef_path) - .map_err(|e| format!("Failed to read cached typedef for `{}`: {}", pkg_path, e))?; + let pkg_path = value["ImportPath"].as_str().unwrap_or("").to_string(); + let relative = pkg_path.strip_prefix(module_path).unwrap_or(&pkg_path); + if relative.split('/').any(|seg| seg == "internal") { + continue; + } - for import_path in extract_third_party_imports(&typedef) { - let containing = self.find_containing_module(&import_path).map_err(|e| { - format!( - "Failed to resolve transitive import `{}` from `{}`: {}", - import_path, pkg_path, e - ) - })?; + if let Some(err) = value["Error"]["Err"].as_str() + && !is_benign_package_error(err) + { + package_errors.push(PackageError { + package: pkg_path.clone(), + message: err.to_string(), + }); + } - if containing.path == module.path || seen.contains(&containing.path) { - continue; + if let Some(deps_errors) = value["DepsErrors"].as_array() { + for de in deps_errors { + if let Some(err) = de["Err"].as_str() + && !is_benign_package_error(err) + { + package_errors.push(PackageError { + package: pkg_path.clone(), + message: err.to_string(), + }); + } } + } - seen.insert(containing.path.clone()); - third_party_deps.push(containing.path); + let Some(imports) = value["Imports"].as_array() else { + continue; + }; + for imp in imports { + if let Some(s) = imp.as_str() { + import_set.insert(s.to_string()); + } } } - Ok(third_party_deps) + let mut third_party: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + + for import in &import_set { + if !deps::is_third_party(import) { + continue; + } + let containing = match self.find_containing_module(import) { + Ok(info) => info, + Err(msg) => { + crate::output::print_warning(&format!( + "could not resolve transitive import `{}` from `{}`: {}; declare it manually with `lis add {}` if your code references it", + import, module_path, msg, import + )); + continue; + } + }; + if containing.path == module_path { + continue; + } + if seen.insert(containing.path.clone()) { + third_party.push(containing.path); + } + } + + third_party.sort(); + Ok(ListedModules { + modules: third_party, + package_errors, + }) } } +pub struct ListedModules { + pub modules: Vec, + pub package_errors: Vec, +} + +pub struct PackageError { + pub package: String, + pub message: String, +} + +fn is_benign_package_error(message: &str) -> bool { + message.contains("build constraints exclude all Go files") || message.contains("no Go files in") +} + /// Translate raw `go` stderr into a one-line message for the common failure modes. /// /// Falls back to the trimmed stderr verbatim if no pattern matches, so callers @@ -475,9 +527,10 @@ fn translate_go_error(args: &[&str], stderr: &str) -> String { return "`-insecure` is no longer a valid Go flag; remove it from `GOFLAGS` or set `GOINSECURE` instead".to_string(); } if stderr.contains("unrecognized import path") { + let offender = extract_unrecognized_path(stderr).unwrap_or(module.to_string()); return format!( "`{}` is not a recognized Go module path; the host does not serve `go-import` metadata", - module + offender ); } if stderr.contains("updates to go.mod needed") { @@ -536,6 +589,25 @@ fn extract_path_mismatch(stderr: &str) -> Option<(String, String)> { Some((declared, required)) } +/// Pull `X` out of `unrecognized import path "X"` (Go's quoted form) or +/// `X: unrecognized import path` (the colon-prefixed form). +fn extract_unrecognized_path(stderr: &str) -> Option { + if let Some(rest) = stderr.split("unrecognized import path \"").nth(1) + && let Some(path) = rest.split('"').next() + && !path.is_empty() + { + return Some(path.to_string()); + } + let line = stderr + .lines() + .find(|l| l.contains(": unrecognized import path"))?; + let path = line.split(": unrecognized import path").next()?.trim(); + if path.is_empty() { + return None; + } + Some(path.trim_start_matches("go: ").to_string()) +} + /// Pull `(found_module, missing_package)` out of a Go missing-subpackage error: /// /// ```text @@ -604,7 +676,7 @@ impl GoWorkspace<'_> { continue; } - if let Err(e) = fs::write(&pkg_typedef_path, &entry.content) { + if let Err(e) = atomic_write(&pkg_typedef_path, &entry.content) { outcome.failures.push(format!( "Failed to cache typedef for `{}`: {}", entry.package, e @@ -621,6 +693,25 @@ impl GoWorkspace<'_> { } } +fn atomic_write(path: &Path, content: &str) -> Result<(), String> { + let mut tmp_os = path.as_os_str().to_owned(); + tmp_os.push(".tmp"); + let tmp_path = std::path::PathBuf::from(tmp_os); + + fs::write(&tmp_path, content) + .map_err(|e| format!("Failed to write `{}`: {}", tmp_path.display(), e))?; + fs::rename(&tmp_path, path).map_err(|e| { + let _ = fs::remove_file(&tmp_path); + format!( + "Failed to rename `{}` to `{}`: {}", + tmp_path.display(), + path.display(), + e + ) + })?; + Ok(()) +} + fn validate_typedef_parses(pkg_path: &str, typedef: &str) -> Result<(), String> { let parse = Parser::lex_and_parse_file(typedef, 0); if !parse.failed() { @@ -634,26 +725,123 @@ fn validate_typedef_parses(pkg_path: &str, typedef: &str) -> Result<(), String> )) } -fn extract_third_party_imports(typedef: &str) -> Vec { +/// Every non-blank `go:` import in a typedef. Blank-aliased imports are +/// skipped since callers must not bindgen link-only packages. +pub(crate) fn extract_go_imports(typedef: &str) -> Vec { let parse_result = Parser::lex_and_parse_file(typedef, 0); parse_result .ast .iter() .filter_map(|expr| match expr { - Expression::ModuleImport { name, .. } => { - let pkg = name.strip_prefix("go:")?; - if deps::is_third_party(pkg) { - Some(pkg.to_string()) - } else { - None + Expression::ModuleImport { name, alias, .. } => { + if matches!(alias, Some(ImportAlias::Blank(_))) { + return None; } + Some(name.strip_prefix("go:")?.to_string()) } _ => None, }) .collect() } +/// `Bindgen` impl backed by a `GoWorkspace`. The internal `Mutex<()>` +/// serializes intra-process threads; the target flock serializes processes. +#[derive(Debug)] +pub struct WorkspaceBindgen { + target_dir: PathBuf, + typedef_cache_dir: PathBuf, + target: stdlib::Target, + mutex: Mutex<()>, + go_present: OnceLock, + progress_emitted: AtomicBool, +} + +impl WorkspaceBindgen { + pub fn new(target_dir: PathBuf, typedef_cache_dir: PathBuf, target: stdlib::Target) -> Self { + Self { + target_dir, + typedef_cache_dir, + target, + mutex: Mutex::new(()), + go_present: OnceLock::new(), + progress_emitted: AtomicBool::new(false), + } + } + + pub fn progress_emitted(&self) -> bool { + self.progress_emitted.load(Ordering::Relaxed) + } +} + +#[derive(Debug, Default)] +pub struct WorkspaceBindgenSetup; + +impl BindgenSetup for WorkspaceBindgenSetup { + fn for_project( + &self, + project_root: &Path, + target: stdlib::Target, + ) -> Result { + let (manifest, _) = deps::TypedefLocator::from_project_with_manifest(project_root)?; + + let target_dir = project_root.join("target"); + if target_dir.is_file() { + return Err(format!( + "`{}` exists but is a file, not a directory", + target_dir.display() + )); + } + fs::create_dir_all(&target_dir) + .map_err(|e| format!("Failed to create `{}`: {}", target_dir.display(), e))?; + + let lock = crate::lock::acquire_target_lock_quiet(&target_dir)?; + + let manifest_locator = + deps::TypedefLocator::new(manifest.go_deps(), Some(project_root.to_path_buf()), target); + crate::go_cli::write_go_mod(&target_dir, &manifest.project.name, &manifest_locator)?; + + let typedef_cache_dir = deps::typedef_cache_dir(project_root); + let bindgen: std::sync::Arc = + std::sync::Arc::new(WorkspaceBindgen::new(target_dir, typedef_cache_dir, target)); + + Ok(BindgenSession::new(bindgen, Box::new(lock))) + } +} + +impl Bindgen for WorkspaceBindgen { + fn run(&self, pkg: &GoPackage<'_>) -> Result<(), BindgenFailure> { + let _guard = self + .mutex + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + + let typedef_path = pkg.typedef_path(&self.typedef_cache_dir, self.target); + if typedef_path.exists() { + return Ok(()); + } + + if !*self.go_present.get_or_init(crate::go_cli::is_go_present) { + return Err(BindgenFailure::GoToolchainMissing); + } + + crate::output::print_progress(&format!("Generating typedef for {}", pkg.package)); + self.progress_emitted.store(true, Ordering::Relaxed); + + let workspace = GoWorkspace::new(&self.target_dir, &self.typedef_cache_dir, self.target); + + let module = GoModule { + path: pkg.module.path, + version: pkg.module.version, + }; + + match workspace.reconcile_package(module, pkg.package) { + Ok(_stubs) => Ok(()), + Err(stderr) => Err(BindgenFailure::InvocationFailed { stderr }), + } + } +} + #[cfg(debug_assertions)] fn dev_bindgen_path() -> Option { let path = std::path::PathBuf::from(concat!( diff --git a/crates/deps/src/lib.rs b/crates/deps/src/lib.rs index d2c77728..55f8d114 100644 --- a/crates/deps/src/lib.rs +++ b/crates/deps/src/lib.rs @@ -10,7 +10,10 @@ pub use project_manifest::{ check_toolchain_version, parse_manifest, remove_go_dep, resolve_empty_via, trim_dead_via_parents, upsert_go_dep, validate_project_name, }; -pub use typedef_locator::{TypedefLocator, TypedefLocatorResult, TypedefOrigin}; +pub use typedef_locator::{ + Bindgen, BindgenFailure, BindgenGuard, BindgenSession, BindgenSetup, DeclarationStatus, + TypedefLocator, TypedefLocatorResult, TypedefOrigin, +}; pub fn is_third_party(pkg: &str) -> bool { pkg.split('/') diff --git a/crates/deps/src/typedef_locator.rs b/crates/deps/src/typedef_locator.rs index 31a51159..10db0245 100644 --- a/crates/deps/src/typedef_locator.rs +++ b/crates/deps/src/typedef_locator.rs @@ -1,6 +1,7 @@ use std::borrow::Cow; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; +use std::sync::Arc; use stdlib::Target; @@ -20,16 +21,86 @@ pub enum TypedefLocatorResult { UnknownStdlib, /// Has a domain-style path but is not declared in the manifest. UndeclaredImport, - /// Declared in the manifest but no `.d.lis` file found on disk. + /// Declared in the manifest but no `.d.lis` on disk and no bindgen runner. MissingTypedef { module: String, version: String }, /// Typedef file exists but could not be read. UnreadableTypedef { path: PathBuf, error: String }, + /// The bindgen runner ran on cache miss but failed. + BindgenFailed { + module: String, + version: String, + package: String, + kind: BindgenFailure, + }, +} + +/// Why a `Bindgen::run` invocation failed. +#[derive(Debug)] +pub enum BindgenFailure { + /// `go` is not installed or not on PATH. + GoToolchainMissing, + /// The bindgen subprocess failed; `stderr` is the trimmed message. + InvocationFailed { stderr: String }, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Classification of a `go:` import path without touching the cache. +#[derive(Debug)] +pub enum DeclarationStatus { + Stdlib, + DeclaredThirdParty { module: String, version: String }, + UnknownStdlib, + UndeclaredImport, +} + +#[derive(Debug, Clone, PartialEq, Eq)] pub enum TypedefOrigin { Stdlib, - Cache, + Cache(PathBuf), +} + +impl TypedefOrigin { + /// Consume the origin, yielding the on-disk path for cache origins and + /// `None` for embedded stdlib typedefs. + pub fn into_cache_path(self) -> Option { + match self { + TypedefOrigin::Cache(path) => Some(path), + TypedefOrigin::Stdlib => None, + } + } +} + +/// Cache-miss hook the locator invokes for declared third-party packages. +pub trait Bindgen: Send + Sync + std::fmt::Debug { + /// Generate `pkg`'s typedef and write it to the cache (no transitives). + fn run(&self, pkg: &GoPackage) -> Result<(), BindgenFailure>; +} + +/// Per-analysis bindgen runner; dropping the session releases the lock. +pub struct BindgenSession { + pub bindgen: std::sync::Arc, + _guard: Box, +} + +impl BindgenSession { + pub fn new(bindgen: std::sync::Arc, guard: Box) -> Self { + Self { + bindgen, + _guard: guard, + } + } +} + +pub trait BindgenGuard: sealed::Sealed + Send {} + +mod sealed { + pub trait Sealed {} +} + +impl sealed::Sealed for std::fs::File {} +impl BindgenGuard for std::fs::File {} + +pub trait BindgenSetup: Send + Sync { + fn for_project(&self, project_root: &Path, target: Target) -> Result; } #[derive(Debug, Clone, Default)] @@ -37,6 +108,7 @@ pub struct TypedefLocator { deps: BTreeMap, project_root: Option, target: Target, + bindgen: Option>, } impl TypedefLocator { @@ -49,6 +121,7 @@ impl TypedefLocator { deps, project_root, target, + bindgen: None, } } @@ -72,6 +145,11 @@ impl TypedefLocator { Ok((manifest, locator)) } + pub fn with_bindgen(mut self, bindgen: Arc) -> Self { + self.bindgen = Some(bindgen); + self + } + pub fn project_root(&self) -> Option<&Path> { self.project_root.as_deref() } @@ -88,57 +166,119 @@ impl TypedefLocator { find_module_for_pkg(&self.deps, package_path).is_some() } - /// Returns the `.d.lis` content for a Go package (without `go:` prefix). - /// Checks embedded stdlib typedefs first, then the on-disk cache. - pub fn find_typedef_content(&self, package_path: &str) -> TypedefLocatorResult { + /// Classify a `go:` import path without touching the cache or bindgen. + pub fn validate_declaration(&self, package_path: &str) -> DeclarationStatus { + self.classify(package_path) + } + + fn classify(&self, package_path: &str) -> DeclarationStatus { if crate::is_stdlib(package_path) { return match stdlib::get_go_stdlib_typedef(package_path, self.target) { - Some(source) => TypedefLocatorResult::Found { - content: Cow::Borrowed(source), - origin: TypedefOrigin::Stdlib, - }, - None => TypedefLocatorResult::UnknownStdlib, + Some(_) => DeclarationStatus::Stdlib, + None => DeclarationStatus::UnknownStdlib, }; } - let Some((module_path, dep)) = find_module_for_pkg(&self.deps, package_path) else { - return TypedefLocatorResult::UndeclaredImport; - }; + match find_module_for_pkg(&self.deps, package_path) { + Some((module_path, dep)) => DeclarationStatus::DeclaredThirdParty { + module: module_path.to_string(), + version: dep.version.clone(), + }, + None => DeclarationStatus::UndeclaredImport, + } + } - let version = &dep.version; + /// Resolve a `go:` package: stdlib -> on-disk cache -> bindgen runner if set. + pub fn find_typedef_content(&self, package_path: &str) -> TypedefLocatorResult { + let (module_path, version) = match self.classify(package_path) { + DeclarationStatus::Stdlib => { + let source = stdlib::get_go_stdlib_typedef(package_path, self.target) + .expect("Stdlib classification implies an embedded typedef"); + return TypedefLocatorResult::Found { + content: Cow::Borrowed(source), + origin: TypedefOrigin::Stdlib, + }; + } + DeclarationStatus::UnknownStdlib => return TypedefLocatorResult::UnknownStdlib, + DeclarationStatus::UndeclaredImport => return TypedefLocatorResult::UndeclaredImport, + DeclarationStatus::DeclaredThirdParty { module, version } => (module, version), + }; let Some(project_root) = &self.project_root else { return TypedefLocatorResult::MissingTypedef { - module: module_path.to_string(), - version: version.clone(), + module: module_path, + version, }; }; let pkg = GoPackage { module: GoModule { - path: module_path, - version, + path: &module_path, + version: &version, }, package: package_path, }; - let typedef_cache_dir = typedef_cache_dir(project_root); - let typedef_path = pkg.typedef_path(&typedef_cache_dir, self.target); + let cache_dir = typedef_cache_dir(project_root); + let typedef_path = pkg.typedef_path(&cache_dir, self.target); - match std::fs::read_to_string(&typedef_path) { - Ok(source) => TypedefLocatorResult::Found { - content: Cow::Owned(source), - origin: TypedefOrigin::Cache, + match read_typedef(&typedef_path) { + ReadOutcome::Found(content) => TypedefLocatorResult::Found { + content: Cow::Owned(content), + origin: TypedefOrigin::Cache(typedef_path), }, - Err(e) if e.kind() != std::io::ErrorKind::NotFound => { - TypedefLocatorResult::UnreadableTypedef { - path: typedef_path, - error: e.to_string(), - } - } - Err(_) => TypedefLocatorResult::MissingTypedef { - module: module_path.to_string(), - version: version.clone(), + ReadOutcome::Unreadable(error) => TypedefLocatorResult::UnreadableTypedef { + path: typedef_path, + error, + }, + ReadOutcome::Missing => match &self.bindgen { + None => TypedefLocatorResult::MissingTypedef { + module: module_path, + version, + }, + Some(runner) => match runner.run(&pkg) { + Ok(()) => match read_typedef(&typedef_path) { + ReadOutcome::Found(content) => TypedefLocatorResult::Found { + content: Cow::Owned(content), + origin: TypedefOrigin::Cache(typedef_path), + }, + ReadOutcome::Unreadable(error) => TypedefLocatorResult::UnreadableTypedef { + path: typedef_path, + error, + }, + ReadOutcome::Missing => TypedefLocatorResult::BindgenFailed { + module: module_path, + version, + package: package_path.to_string(), + kind: BindgenFailure::InvocationFailed { + stderr: format!( + "bindgen reported success but `{}` was not written", + typedef_path.display() + ), + }, + }, + }, + Err(kind) => TypedefLocatorResult::BindgenFailed { + module: module_path, + version, + package: package_path.to_string(), + kind, + }, + }, }, } } } + +enum ReadOutcome { + Found(String), + Missing, + Unreadable(String), +} + +fn read_typedef(path: &Path) -> ReadOutcome { + match std::fs::read_to_string(path) { + Ok(s) => ReadOutcome::Found(s), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => ReadOutcome::Missing, + Err(e) => ReadOutcome::Unreadable(e.to_string()), + } +} diff --git a/crates/diagnostics/src/lib.rs b/crates/diagnostics/src/lib.rs index 67dd6323..57ddeff2 100644 --- a/crates/diagnostics/src/lib.rs +++ b/crates/diagnostics/src/lib.rs @@ -11,7 +11,7 @@ pub mod pattern; pub mod render; pub use diagnostic::{IndexedSource, LisetteDiagnostic, Report}; -pub use result::{SemanticResult, TypedefSource}; +pub use result::SemanticResult; pub use sink::LocalSink; pub use lint::{IssueKind, UnusedExpressionKind}; diff --git a/crates/diagnostics/src/module_graph.rs b/crates/diagnostics/src/module_graph.rs index e370fe68..b50fa4a6 100644 --- a/crates/diagnostics/src/module_graph.rs +++ b/crates/diagnostics/src/module_graph.rs @@ -119,6 +119,42 @@ pub fn unreadable_go_typedef(path: &std::path::Path, error: &str, span: Span) -> .with_help(format!("Failed to read `{}`: {}", path.display(), error,)) } +pub fn go_toolchain_missing(go_pkg: &str, span: Span) -> LisetteDiagnostic { + LisetteDiagnostic::error(format!( + "Cannot generate Go typedef for `{}`: `go` is not installed", + go_pkg + )) + .with_resolve_code("go_toolchain_missing") + .with_span_label(&span, "needs the Go toolchain") + .with_help("Install Go from https://go.dev/dl/") +} + +pub fn bindgen_failed( + go_pkg: &str, + module: &str, + version: &str, + stderr: &str, + span: Span, +) -> LisetteDiagnostic { + let trimmed = stderr.trim(); + let stderr_block = if trimmed.is_empty() { + String::new() + } else { + format!("\n\n{}", trimmed) + }; + + LisetteDiagnostic::error(format!( + "Failed to generate Go typedef for `{}` ({} {})", + go_pkg, module, version + )) + .with_resolve_code("bindgen_failed") + .with_span_label(&span, "bindgen failed for this import") + .with_help(format!( + "Re-run with `lis bindgen {}` to inspect the failure in isolation.{}", + go_pkg, stderr_block + )) +} + pub fn import_cycle(path: &[String]) -> LisetteDiagnostic { let modules: Vec<_> = path[..path.len() - 1].to_vec(); diff --git a/crates/diagnostics/src/result.rs b/crates/diagnostics/src/result.rs index 81b1f216..04633f54 100644 --- a/crates/diagnostics/src/result.rs +++ b/crates/diagnostics/src/result.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use syntax::ParseError; @@ -6,11 +8,6 @@ use syntax::types::Symbol; use crate::LisetteDiagnostic; -pub struct TypedefSource { - pub source: String, - pub filename: String, -} - pub struct SemanticResult { pub files: HashMap, pub definitions: HashMap, @@ -22,7 +19,10 @@ pub struct SemanticResult { pub mutations: MutationInfo, pub cached_modules: HashSet, pub ufcs_methods: HashSet<(String, String)>, - pub typedef_sources: HashMap, + /// File ID -> on-disk path of the `.d.lis` typedef. Populated for third-party + /// go: typedefs read from `target/.lisette/typedefs/...`; absent for embedded + /// stdlib typedefs. + pub typedef_paths: HashMap, pub go_package_names: HashMap, } @@ -39,7 +39,7 @@ impl SemanticResult { mutations: MutationInfo::default(), cached_modules: HashSet::default(), ufcs_methods: HashSet::default(), - typedef_sources: HashMap::default(), + typedef_paths: HashMap::default(), go_package_names: HashMap::default(), } } diff --git a/crates/lsp/src/analysis.rs b/crates/lsp/src/analysis.rs index f747e58f..73074f02 100644 --- a/crates/lsp/src/analysis.rs +++ b/crates/lsp/src/analysis.rs @@ -109,6 +109,21 @@ impl SharedState { } }; + let (locator, session, bindgen_error) = + if config.standalone_mode || manifest_error.is_some() { + (locator, None, None) + } else if let Some(setup) = self.bindgen_setup.as_ref() { + match setup.for_project(&config.root, locator.target()) { + Ok(session) => { + let with_runner = locator.clone().with_bindgen(session.bindgen.clone()); + (with_runner, Some(session), None) + } + Err(msg) => (locator, None, Some(msg)), + } + } else { + (locator, None, None) + }; + let (mut result, facts) = analyze(AnalyzeInput { config: SemanticConfig { run_lints: !has_parse_errors, @@ -140,6 +155,19 @@ impl SharedState { .push(LisetteDiagnostic::error(msg).with_resolve_code("manifest_error")); } + if let Some(msg) = bindgen_error { + result.errors.push( + LisetteDiagnostic::error(format!( + "Could not start bindgen for this project: {}", + msg + )) + .with_resolve_code("bindgen_setup_failed"), + ); + } + + // Release the target lock before the lock-free snapshot construction. + drop(session); + Ok(AnalysisSnapshot::new( result, facts, diff --git a/crates/lsp/src/definition.rs b/crates/lsp/src/definition.rs index 128fdda5..609cc9e1 100644 --- a/crates/lsp/src/definition.rs +++ b/crates/lsp/src/definition.rs @@ -125,9 +125,6 @@ pub(crate) fn resolve_dot_access_definition( } else if let Some(module_name) = find_module_by_alias(file, root_identifier, &snapshot.result.go_package_names) { - if module_name.starts_with("go:") { - return None; - } let qualified = dotted_path .strip_prefix(root_identifier) .map(|rest| format!("{}{}", module_name, rest)) @@ -148,9 +145,6 @@ pub(crate) fn resolve_dot_access_definition( if let Some(module_name) = find_module_by_alias(file, value.as_str(), &snapshot.result.go_package_names) { - if module_name.starts_with("go:") { - return None; - } let qualified = format!("{}.{}", module_name, member); snapshot .definitions() @@ -166,6 +160,15 @@ pub(crate) fn resolve_dot_access_definition( result.or_else(resolve_by_type) } +/// True when the span points into a generated `go:` typedef file. Used by rename +/// to refuse edits to typedefs, which would diverge from the regenerated content. +pub(crate) fn is_go_typedef_span(snapshot: &AnalysisSnapshot, span: &syntax::ast::Span) -> bool { + snapshot + .files() + .get(&span.file_id) + .is_some_and(|f| f.module_id.starts_with("go:")) +} + /// Resolve an import alias to the import statement's span. pub(crate) fn resolve_import_span( name: &str, diff --git a/crates/lsp/src/lib.rs b/crates/lsp/src/lib.rs index 8d082f33..cd460682 100644 --- a/crates/lsp/src/lib.rs +++ b/crates/lsp/src/lib.rs @@ -23,7 +23,7 @@ use crate::completion::{ get_module_prefix, get_type_completions, resolve_variable_type, }; use crate::definition::{ - find_struct_field_span, lookup_definition_span, resolve_definition_span, + find_struct_field_span, is_go_typedef_span, lookup_definition_span, resolve_definition_span, resolve_dot_access_definition, resolve_enum_in_pattern, resolve_import_span, resolve_match_pattern_definition, resolve_struct_call_field, resolve_word_at_offset, word_at_offset, @@ -386,6 +386,13 @@ impl LanguageServer for Backend { return Ok(None); }; + // Stdlib go: typedefs are loaded from cache without registering a File, + // so their definitions carry Span::dummy(). Refuse to navigate rather + // than jump to (0,0) of whatever happens to be at file_id 0. + if definition_span.is_dummy() { + return Ok(None); + } + if let Some(target_file) = snapshot.files().get(&definition_span.file_id) { let end = (definition_span.byte_offset as usize) .saturating_add(definition_span.byte_length as usize); @@ -863,7 +870,10 @@ impl LanguageServer for Backend { span, .. } if !member.is_empty() => { - if resolve_dot_access_definition(expression, member, file, &snapshot).is_some() { + let resolved = resolve_dot_access_definition(expression, member, file, &snapshot); + if let Some(definition_span) = resolved + && !is_go_typedef_span(&snapshot, &definition_span) + { let member_span = syntax::ast::Span::new( span.file_id, span.byte_offset + span.byte_length - member.len() as u32, @@ -977,7 +987,8 @@ impl LanguageServer for Backend { syntax::ast::Expression::DotAccess { expression, member, .. - } => resolve_dot_access_definition(expression, member, file, &snapshot), + } => resolve_dot_access_definition(expression, member, file, &snapshot) + .filter(|s| !is_go_typedef_span(&snapshot, s)), syntax::ast::Expression::Match { arms, .. } => { resolve_match_pattern_definition(arms, offset, file, &snapshot) diff --git a/crates/lsp/src/snapshot.rs b/crates/lsp/src/snapshot.rs index ed0c17b2..85a9c780 100644 --- a/crates/lsp/src/snapshot.rs +++ b/crates/lsp/src/snapshot.rs @@ -57,6 +57,16 @@ impl AnalysisSnapshot { } else { continue; } + } else if let Some(typedef_path) = result.typedef_paths.get(file_id) { + // The synthetic `file.name` for go: typedefs does not match the + // on-disk filename — use the path the locator captured. + match Url::from_file_path(typedef_path) { + Ok(uri) => uri, + Err(_) => continue, + } + } else if file.module_id.starts_with("go:") { + // Stdlib go: typedef is embedded; nothing on disk to navigate to. + continue; } else { let path = module_file_to_path(config, &file.module_id, &file.name); match Url::from_file_path(&path) { diff --git a/crates/lsp/src/state.rs b/crates/lsp/src/state.rs index 51202f00..f388f1aa 100644 --- a/crates/lsp/src/state.rs +++ b/crates/lsp/src/state.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use std::sync::atomic::AtomicU64; use dashmap::DashMap; +use deps::BindgenSetup; use tokio::task::AbortHandle; use tower_lsp::Client; use tower_lsp::lsp_types::Url; @@ -21,6 +22,7 @@ pub struct SharedState { pub(crate) last_valid_snapshot: DashMap>, pub(crate) pending_diagnostics: DashMap, pub(crate) diagnostics_generation: AtomicU64, + pub(crate) bindgen_setup: Option>, } pub struct Backend { @@ -46,7 +48,7 @@ pub(crate) struct DocumentState { } impl Backend { - pub fn new(client: Client) -> Self { + pub fn new(client: Client, bindgen_setup: Option>) -> Self { let placeholder_config = ProjectConfig { root: PathBuf::from("."), standalone_mode: true, @@ -62,6 +64,7 @@ impl Backend { last_valid_snapshot: DashMap::new(), pending_diagnostics: DashMap::new(), diagnostics_generation: AtomicU64::new(0), + bindgen_setup, }), } } diff --git a/crates/semantics/src/analyze.rs b/crates/semantics/src/analyze.rs index bfb6ad03..02fbb802 100644 --- a/crates/semantics/src/analyze.rs +++ b/crates/semantics/src/analyze.rs @@ -2,7 +2,7 @@ use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use std::path::PathBuf; use std::sync::Arc; -use diagnostics::{LocalSink, SemanticResult, TypedefSource}; +use diagnostics::{LocalSink, SemanticResult}; use syntax::ast::Expression; use syntax::program::{File, ModuleInfo, MutationInfo, UnusedInfo}; @@ -15,6 +15,7 @@ use crate::cache::{ save_module_cache, try_load_cache, }; use crate::checker::TaskState; +use crate::diagnostics::emit_for_locator_result; use crate::facts::{BindingIdAllocator, Facts}; use crate::loader::Loader; use crate::module_graph::build_module_graph; @@ -132,6 +133,10 @@ pub fn analyze(input: AnalyzeInput) -> (SemanticResult, Facts) { for module_id in order { if let Some(go_pkg) = module_id.strip_prefix("go:") { + if graph_result.link_only_modules.contains(&module_id) { + continue; + } + if deps::is_stdlib(go_pkg) && let Some(ref cache) = go_cache { @@ -141,16 +146,27 @@ pub fn analyze(input: AnalyzeInput) -> (SemanticResult, Facts) { } } - if let deps::TypedefLocatorResult::Found { - content: source, .. - } = input.locator.find_typedef_content(go_pkg) - { - checker.parse_and_register_go_module( - &mut store, - &module_id, - &source, - &input.locator, - ); + match input.locator.find_typedef_content(go_pkg) { + deps::TypedefLocatorResult::Found { content, origin } => { + checker.parse_and_register_go_module( + &mut store, + &module_id, + content.as_ref(), + origin.into_cache_path(), + &input.locator, + ); + } + other => { + emit_for_locator_result( + &other, + &module_id, + go_pkg, + None, + input.locator.target(), + input.config.standalone_mode, + &sink, + ); + } } continue; } @@ -291,23 +307,17 @@ pub fn analyze(input: AnalyzeInput) -> (SemanticResult, Facts) { let mut files = HashMap::default(); let mut definitions = HashMap::default(); let mut modules = HashMap::default(); - let mut typedef_sources = HashMap::default(); for (mod_id, module) in store.modules { let is_internal = module.is_internal(); definitions.extend(module.definitions); + // Internal modules (prelude, **nominal, go:...) stay out of `modules` + // so emit and lints skip them; their typedef files still join `files` + // so the LSP can map typedef file IDs to URIs for go-to-definition. if is_internal { - typedef_sources.extend(module.typedefs.into_iter().map(|(id, file)| { - ( - id, - TypedefSource { - source: file.source, - filename: file.name, - }, - ) - })); + files.extend(module.typedefs); continue; } @@ -336,7 +346,7 @@ pub fn analyze(input: AnalyzeInput) -> (SemanticResult, Facts) { mutations, cached_modules, ufcs_methods, - typedef_sources, + typedef_paths: store.typedef_paths, go_package_names: store.go_package_names, }; diff --git a/crates/semantics/src/checker/registration/mod.rs b/crates/semantics/src/checker/registration/mod.rs index 8f607edf..afa80ad5 100644 --- a/crates/semantics/src/checker/registration/mod.rs +++ b/crates/semantics/src/checker/registration/mod.rs @@ -3,6 +3,8 @@ mod convert; mod methods; mod types; +use std::path::PathBuf; + use rustc_hash::FxHashMap as HashMap; use deps::TypedefLocator; @@ -107,12 +109,14 @@ impl TaskState<'_> { /// Register a Go module (stdlib or third-party). Unlike regular modules, /// Go modules export everything as public and do not put their own module - /// in scope (no self-references like `MyModule.Type`). + /// in scope (no self-references like `MyModule.Type`). `cache_path` is the + /// on-disk typedef location, or `None` for embedded stdlib typedefs. pub fn parse_and_register_go_module( &mut self, store: &mut Store, module_id: &str, source: &str, + cache_path: Option, locator: &TypedefLocator, ) { if store.is_visited(module_id) { @@ -152,16 +156,44 @@ impl TaskState<'_> { for import in &imports { if let Some(go_pkg) = import.name.strip_prefix("go:") { + if matches!(import.alias, Some(syntax::ast::ImportAlias::Blank(_))) { + continue; + } + let import_module_id = format!("go:{}", go_pkg); - if let deps::TypedefLocatorResult::Found { - content: source, .. - } = locator.find_typedef_content(go_pkg) - { - self.parse_and_register_go_module(store, &import_module_id, &source, locator); + + if store.is_visited(&import_module_id) { + continue; + } + + match locator.find_typedef_content(go_pkg) { + deps::TypedefLocatorResult::Found { content, origin } => { + self.parse_and_register_go_module( + store, + &import_module_id, + content.as_ref(), + origin.into_cache_path(), + locator, + ); + } + other => { + crate::diagnostics::emit_for_locator_result( + &other, + &import.name, + go_pkg, + Some(import.name_span), + locator.target(), + false, + self.sink, + ); + } } } } + if let Some(path) = cache_path { + store.typedef_paths.insert(file_id, path); + } store.store_file(module_id, file); self.with_file_context( diff --git a/crates/semantics/src/diagnostics.rs b/crates/semantics/src/diagnostics.rs new file mode 100644 index 00000000..abaabb7c --- /dev/null +++ b/crates/semantics/src/diagnostics.rs @@ -0,0 +1,131 @@ +use deps::{BindgenFailure, DeclarationStatus, TypedefLocatorResult}; +use diagnostics::LocalSink; +use stdlib::Target; +use syntax::ast::Span; + +pub fn emit_for_locator_result( + result: &TypedefLocatorResult, + import_name: &str, + go_pkg: &str, + name_span: Option, + target: Target, + standalone_mode: bool, + sink: &LocalSink, +) -> bool { + let span = name_span.unwrap_or_else(|| Span::new(0, 0, 0)); + match result { + TypedefLocatorResult::Found { .. } => return true, + TypedefLocatorResult::UnknownStdlib => { + emit_unknown_stdlib(import_name, go_pkg, span, target, standalone_mode, sink); + } + TypedefLocatorResult::UndeclaredImport => { + emit_undeclared(import_name, go_pkg, span, standalone_mode, sink); + } + TypedefLocatorResult::MissingTypedef { module, version } => { + sink.push(diagnostics::module_graph::missing_go_typedef( + go_pkg, module, version, span, + )); + } + TypedefLocatorResult::UnreadableTypedef { path, error } => { + sink.push(diagnostics::module_graph::unreadable_go_typedef( + path, error, span, + )); + } + TypedefLocatorResult::BindgenFailed { + module, + version, + kind, + .. + } => match kind { + BindgenFailure::GoToolchainMissing => { + sink.push(diagnostics::module_graph::go_toolchain_missing( + go_pkg, span, + )); + } + BindgenFailure::InvocationFailed { stderr } => { + sink.push(diagnostics::module_graph::bindgen_failed( + go_pkg, module, version, stderr, span, + )); + } + }, + } + false +} + +/// Emit a diagnostic for a non-OK `DeclarationStatus`; returns `true` if OK. +pub fn emit_for_declaration_status( + status: &DeclarationStatus, + import_name: &str, + go_pkg: &str, + name_span: Span, + target: Target, + standalone_mode: bool, + sink: &LocalSink, +) -> bool { + match status { + DeclarationStatus::Stdlib | DeclarationStatus::DeclaredThirdParty { .. } => true, + DeclarationStatus::UnknownStdlib => { + emit_unknown_stdlib( + import_name, + go_pkg, + name_span, + target, + standalone_mode, + sink, + ); + false + } + DeclarationStatus::UndeclaredImport => { + emit_undeclared(import_name, go_pkg, name_span, standalone_mode, sink); + false + } + } +} + +fn emit_unknown_stdlib( + import_name: &str, + go_pkg: &str, + span: Span, + target: Target, + standalone_mode: bool, + sink: &LocalSink, +) { + if let Some(targets) = stdlib::get_go_stdlib_package_targets(go_pkg) { + sink.push(diagnostics::module_graph::go_stdlib_unavailable_on_target( + go_pkg, + &target.to_string(), + &stdlib::format_targets(targets), + span, + )); + } else { + sink.push(diagnostics::module_graph::module_not_found( + import_name, + span, + false, + standalone_mode, + None, + )); + } +} + +fn emit_undeclared( + import_name: &str, + go_pkg: &str, + span: Span, + standalone_mode: bool, + sink: &LocalSink, +) { + if standalone_mode { + sink.push(diagnostics::module_graph::module_not_found( + import_name, + span, + false, + true, + None, + )); + } else { + sink.push(diagnostics::module_graph::undeclared_go_import( + go_pkg, span, + )); + } +} diff --git a/crates/semantics/src/lib.rs b/crates/semantics/src/lib.rs index 41e2b1c8..11562941 100644 --- a/crates/semantics/src/lib.rs +++ b/crates/semantics/src/lib.rs @@ -3,6 +3,7 @@ pub mod cache; pub mod call_classification; pub mod checker; pub mod context; +pub mod diagnostics; pub mod facts; pub mod loader; pub mod module_graph; diff --git a/crates/semantics/src/module_graph/mod.rs b/crates/semantics/src/module_graph/mod.rs index 0e805572..b7cdbddd 100644 --- a/crates/semantics/src/module_graph/mod.rs +++ b/crates/semantics/src/module_graph/mod.rs @@ -2,10 +2,11 @@ pub mod kahn; use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; -use deps::{TypedefLocator, TypedefLocatorResult}; -use syntax::ast::Span; +use deps::TypedefLocator; +use syntax::ast::{ImportAlias, Span}; use syntax::program::File; +use crate::diagnostics::{emit_for_declaration_status, emit_for_locator_result}; use crate::loader::Loader; use crate::store::Store; use diagnostics::LocalSink; @@ -20,6 +21,8 @@ pub struct ModuleGraphResult { /// Direct dependencies of each module (module_id -> set of dependency module_ids). /// Used for transitive cache invalidation. pub edges: HashMap>, + /// `go:` modules that are only ever blank-imported in the visited file set. + pub link_only_modules: HashSet, } pub fn build_module_graph( @@ -35,6 +38,7 @@ pub fn build_module_graph( let mut visited = HashSet::default(); let mut files: HashMap> = HashMap::default(); let mut import_spans: HashMap = HashMap::default(); + let mut blank_tracker = BlankTracker::default(); while let Some(module_id) = to_visit.pop() { if visited.contains(&module_id) { @@ -42,8 +46,15 @@ pub fn build_module_graph( } visited.insert(module_id.clone()); - let (imports_with_spans, module_files) = - collect_imports(&module_id, store, loader, sink, standalone_mode, locator); + let (imports_with_spans, module_files) = collect_imports( + &module_id, + store, + loader, + sink, + standalone_mode, + locator, + &mut blank_tracker, + ); let module_exists = !module_files.is_empty() || store.has(&module_id) @@ -94,6 +105,34 @@ pub fn build_module_graph( cycles, files, edges, + link_only_modules: blank_tracker.into_link_only_modules(), + } +} + +#[derive(Clone, Copy)] +enum SeenLookup { + OkBlank, + OkNonBlank, + Errored, +} + +#[derive(Default)] +struct BlankTracker { + blank: HashSet, + non_blank: HashSet, +} + +impl BlankTracker { + fn record_blank(&mut self, module_id: &str) { + self.blank.insert(module_id.to_string()); + } + + fn record_non_blank(&mut self, module_id: &str) { + self.non_blank.insert(module_id.to_string()); + } + + fn into_link_only_modules(self) -> HashSet { + self.blank.difference(&self.non_blank).cloned().collect() } } @@ -136,8 +175,10 @@ fn collect_imports( sink: &LocalSink, standalone_mode: bool, locator: &TypedefLocator, + blank_tracker: &mut BlankTracker, ) -> (HashMap, Vec) { let mut imports = HashMap::default(); + let mut seen_go_imports: HashMap = HashMap::default(); let (files, file_imports): (Vec, Vec<_>) = if let Some(module) = store.get_module(module_id) { @@ -162,60 +203,72 @@ fn collect_imports( } if let Some(go_pkg) = file_import.name.strip_prefix("go:") { - match locator.find_typedef_content(go_pkg) { - TypedefLocatorResult::Found { .. } => { - imports.insert(file_import.name.to_string(), file_import.name_span); - } - TypedefLocatorResult::UnknownStdlib => { - if let Some(targets) = stdlib::get_go_stdlib_package_targets(go_pkg) { - let target = locator.target(); - sink.push(diagnostics::module_graph::go_stdlib_unavailable_on_target( - go_pkg, - &target.to_string(), - &stdlib::format_targets(targets), - file_import.name_span, - )); - } else { - sink.push(diagnostics::module_graph::module_not_found( - &file_import.name, - file_import.name_span, - false, - standalone_mode, - None, - )); - } - } - TypedefLocatorResult::UndeclaredImport => { - if standalone_mode { - sink.push(diagnostics::module_graph::module_not_found( - &file_import.name, - file_import.name_span, - false, - true, - None, - )); + let is_blank = matches!(file_import.alias, Some(ImportAlias::Blank(_))); + + let prior = seen_go_imports.get(file_import.name.as_str()).copied(); + let needs_lookup = match (prior, is_blank) { + (None, _) => true, + (Some(SeenLookup::Errored), _) => false, + (Some(SeenLookup::OkNonBlank), _) => false, + (Some(SeenLookup::OkBlank), true) => false, + (Some(SeenLookup::OkBlank), false) => true, + }; + + if !needs_lookup { + if matches!(prior, Some(SeenLookup::OkBlank | SeenLookup::OkNonBlank)) { + let module_key = file_import.name.to_string(); + if is_blank { + blank_tracker.record_blank(&module_key); } else { - sink.push(diagnostics::module_graph::undeclared_go_import( - go_pkg, - file_import.name_span, - )); + blank_tracker.record_non_blank(&module_key); } + imports.insert(module_key, file_import.name_span); } - TypedefLocatorResult::MissingTypedef { module, version } => { - sink.push(diagnostics::module_graph::missing_go_typedef( - go_pkg, - &module, - &version, - file_import.name_span, - )); - } - TypedefLocatorResult::UnreadableTypedef { path, error } => { - sink.push(diagnostics::module_graph::unreadable_go_typedef( - &path, - &error, - file_import.name_span, - )); + continue; + } + + let ok = if is_blank { + let status = locator.validate_declaration(go_pkg); + emit_for_declaration_status( + &status, + &file_import.name, + go_pkg, + file_import.name_span, + locator.target(), + standalone_mode, + sink, + ) + } else { + let result = locator.find_typedef_content(go_pkg); + emit_for_locator_result( + &result, + &file_import.name, + go_pkg, + Some(file_import.name_span), + locator.target(), + standalone_mode, + sink, + ) + }; + + seen_go_imports.insert( + file_import.name.to_string(), + if !ok { + SeenLookup::Errored + } else if is_blank { + SeenLookup::OkBlank + } else { + SeenLookup::OkNonBlank + }, + ); + if ok { + let module_key = file_import.name.to_string(); + if is_blank { + blank_tracker.record_blank(&module_key); + } else { + blank_tracker.record_non_blank(&module_key); } + imports.insert(module_key, file_import.name_span); } continue; } diff --git a/crates/semantics/src/store.rs b/crates/semantics/src/store.rs index 8517bfdc..d7d2744b 100644 --- a/crates/semantics/src/store.rs +++ b/crates/semantics/src/store.rs @@ -1,6 +1,8 @@ -use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; +use std::path::PathBuf; use std::sync::atomic::{AtomicU32, Ordering}; +use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; + use syntax::ast::{EnumVariant, Expression, StructFieldDefinition}; use syntax::program::{ Definition, DefinitionBody, File, Interface, MethodSignatures, Module, ModuleId, @@ -18,6 +20,9 @@ pub struct Store { /// Go module ID -> Go package name, from the typedef `// Package:` directive. /// Present only when the package name differs from the final path segment. pub go_package_names: HashMap, + /// File ID -> on-disk path of the `.d.lis` typedef. Lets the LSP map go: typedef + /// file IDs to the actual cache path so go-to-definition can navigate there. + pub typedef_paths: HashMap, visited_modules: HashSet, /// File ID counter. Starts at 2 because 0 is reserved for entry, 1 for prelude. next_file_id: AtomicU32, @@ -48,6 +53,7 @@ impl Store { modules, module_ids, go_package_names: Default::default(), + typedef_paths: Default::default(), visited_modules: Default::default(), next_file_id: AtomicU32::new(2), // 0 = entrypoint, 1 = prelude } diff --git a/tests/_harness/infer.rs b/tests/_harness/infer.rs index cd82a2fa..e506db44 100644 --- a/tests/_harness/infer.rs +++ b/tests/_harness/infer.rs @@ -56,7 +56,9 @@ pub fn infer_module(module_name: &str, fs: MockFileSystem) -> InferResult { for module_id in order { if let Some(go_pkg) = module_id.strip_prefix("go:") { if let Some(typedef) = get_go_stdlib_typedef(go_pkg, Target::host()) { - checker.parse_and_register_go_module(&mut store, &module_id, typedef, &locator); + checker.parse_and_register_go_module( + &mut store, &module_id, typedef, None, &locator, + ); } continue; } diff --git a/tests/_harness/pipeline.rs b/tests/_harness/pipeline.rs index 681e7d4b..68c71d4a 100644 --- a/tests/_harness/pipeline.rs +++ b/tests/_harness/pipeline.rs @@ -110,7 +110,7 @@ impl CompiledTest { let locator = deps::TypedefLocator::default(); for (name, typedef) in &self.extra_go_typedefs { - checker.parse_and_register_go_module(&mut store, name, typedef, &locator); + checker.parse_and_register_go_module(&mut store, name, typedef, None, &locator); } let imports: Vec = self @@ -127,8 +127,9 @@ impl CompiledTest { if let Some(go_pkg) = name.strip_prefix("go:") && let Some(typedef) = get_go_stdlib_typedef(go_pkg, Target::host()) { - checker - .parse_and_register_go_module(&mut store, name, typedef, &locator); + checker.parse_and_register_go_module( + &mut store, name, typedef, None, &locator, + ); } Some(FileImport { name: name.clone(), diff --git a/tests/lsp.rs b/tests/lsp.rs index bb561505..9d64b386 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -553,6 +553,80 @@ async fn goto_definition_on_literal_returns_none() { client.shutdown().await; } +#[tokio::test] +async fn goto_definition_on_stdlib_go_function_returns_none() { + let mut client = TestClient::new().await; + client.initialize().await; + + let source = "import \"go:fmt\"\n\nfn main() {\n fmt.Println(\"hello\")\n}"; + client.open(TEST_URI, source).await; + + // Stdlib go: typedefs are embedded in the binary; no on-disk file exists. + // The handler must return None rather than navigate to (0,0) of the entry file. + let response = client.goto_definition(TEST_URI, 3, 6).await; + assert!( + response.is_none(), + "stdlib go: F12 should return None, got {:?}", + response + ); + + client.shutdown().await; +} + +#[tokio::test] +async fn goto_definition_on_third_party_go_function_navigates_to_cache() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + + let manifest = format!( + "[project]\nname = \"test\"\nversion = \"0.0.1\"\n\n[toolchain]\nlis = \"{}\"\n\n[dependencies.go]\n\"github.com/example/lib\" = \"v1.0.0\"\n", + env!("CARGO_PKG_VERSION") + ); + std::fs::write(root.join("lisette.toml"), manifest).unwrap(); + + // Pre-populate the typedef cache for `github.com/example/lib@v1.0.0`. + let pkg = deps::GoPackage { + module: deps::GoModule { + path: "github.com/example/lib", + version: "v1.0.0", + }, + package: "github.com/example/lib", + }; + let cache_dir = deps::typedef_cache_dir(root); + let typedef_path = pkg.typedef_path(&cache_dir, stdlib::Target::host()); + std::fs::create_dir_all(typedef_path.parent().unwrap()).unwrap(); + std::fs::write( + &typedef_path, + "// Package: lib\n\npub fn DoStuff() -> int\n", + ) + .unwrap(); + + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + let main_content = + "import \"go:github.com/example/lib\"\n\nfn main() {\n let _ = lib.DoStuff()\n}"; + let main_path = src.join("main.lis"); + std::fs::write(&main_path, main_content).unwrap(); + + let mut client = TestClient::new().await; + client.initialize_with_root(root).await; + + let main_uri = Url::from_file_path(&main_path).unwrap().to_string(); + client.open(&main_uri, main_content).await; + + // Cursor on `DoStuff`. + let response = client.goto_definition(&main_uri, 3, 14).await; + let location = definition_location( + &response.expect("F12 on third-party go: function should return a location"), + ) + .expect("response should contain a location"); + + let typedef_uri = Url::from_file_path(&typedef_path).unwrap(); + assert_eq!(location.uri, typedef_uri); + + client.shutdown().await; +} + #[tokio::test] async fn completion_empty_file() { let mut client = TestClient::new().await; diff --git a/tests/lsp_harness.rs b/tests/lsp_harness.rs index e2b665ef..0111fd83 100644 --- a/tests/lsp_harness.rs +++ b/tests/lsp_harness.rs @@ -69,7 +69,7 @@ impl TestClient { let (server_read, server_write) = tokio::io::split(server); let (client_read, client_write) = tokio::io::split(client); - let (service, socket) = LspService::new(Backend::new); + let (service, socket) = LspService::new(|client| Backend::new(client, None)); tokio::spawn(Server::new(server_read, server_write, socket).serve(service)); Self {