diff --git a/crates/ide-build/src/gradle.rs b/crates/ide-build/src/gradle.rs index f8a593ec7..24b469ef6 100644 --- a/crates/ide-build/src/gradle.rs +++ b/crates/ide-build/src/gradle.rs @@ -109,11 +109,11 @@ fn strip_comments(text: &str) -> String { /// Quoted string literals in `s`. fn literals(s: &str) -> Vec { let mut out = Vec::new(); - let mut chars = s.chars(); - while let Some(c) = chars.next() { - if c == '"' || c == '\'' { - out.push(chars.by_ref().take_while(|&n| n != c).collect()); - } + let mut rest = s; + while let Some(start) = rest.find(['"', '\'']) { + let Some((value, used)) = literal(&rest[start..]) else { break }; + out.push(value); + rest = &rest[start + used..]; } out } @@ -565,11 +565,12 @@ fn is_dependency_configuration(name: &str) -> bool { /// `(group, artifact, version)` of the external dependencies a build script declares: /// `implementation 'g:a:v'`, `api("g:a:v:classifier@jar")`, and the map notation /// `implementation group: 'g', name: 'a', version: 'v'` (Groovy) / `group = "g", …` (Kotlin). -/// Versions with interpolations, ranges and catalog aliases (`libs.x`) are skipped. -pub fn script_dependencies(text: &str) -> Vec<(String, String, String)> { +/// Versions written as `${gsonVersion}` are filled in from `properties` (`ext` values, see +/// [`script_properties`]); ranges, unknown names and catalog aliases (`libs.x`) are skipped. +pub fn script_dependencies(text: &str, properties: &BTreeMap) -> Vec<(String, String, String)> { let text = strip_comments(text); let mut out = Vec::new(); - for line in text.lines().flat_map(|l| l.split(['{', ';'])) { + for line in text.lines().flat_map(statements) { let line = line.trim_start(); let name: String = line.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect(); if name.is_empty() || !is_dependency_configuration(&name) { @@ -591,6 +592,7 @@ pub fn script_dependencies(text: &str) -> Vec<(String, String, String)> { }), }; if let Some((g, a, v)) = coordinate { + let v = interpolate(&v, properties); let plain = |s: &str| { !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '+')) }; @@ -602,11 +604,354 @@ pub fn script_dependencies(text: &str) -> Vec<(String, String, String)> { out } +/// `line` split at `{` and `;` outside quoted strings, so a `${version}` stays in one piece. +fn statements(line: &str) -> Vec<&str> { + let mut out = Vec::new(); + let mut start = 0; + let mut index = 0; + while index < line.len() { + let c = line[index..].chars().next().unwrap(); + if c == '"' || c == '\'' { + index += literal(&line[index..]).map_or(line.len() - index, |(_, used)| used); + continue; + } + if matches!(c, '{' | '}' | ';' | '\n') { + out.push(&line[start..index]); + start = index + c.len_utf8(); + } + index += c.len_utf8(); + } + out.push(&line[start..]); + out +} + +/// `apply from: 'x.gradle'` (Groovy) and `apply(from = "x.gradle.kts")` (Kotlin DSL) +/// targets, as written in the script. +pub fn applied_scripts(text: &str) -> Vec { + let text = strip_comments(text); + let mut out = Vec::new(); + for (index, word) in code_words(&text) { + if word != "apply" || !starts_word(&text, index) { + continue; + } + let mut rest = text[index + "apply".len()..].trim_start(); + if let Some(inner) = rest.strip_prefix('(') { + rest = inner.trim_start(); + } + let Some(rest) = rest.strip_prefix("from") else { continue }; + let Some(rest) = rest.trim_start().strip_prefix(':').or_else(|| rest.trim_start().strip_prefix('=')) else { + continue; + }; + let mut rest = rest.trim_start(); + if let Some(inner) = rest.strip_prefix("file") { + let inner = inner.trim_start(); + if let Some(inner) = inner.strip_prefix('(') { + rest = inner.trim_start(); + } + } + if let Some((path, _)) = literal(rest) { + out.push(path); + } + } + out +} + +/// Literal extra properties declared by Groovy's `ext` or Kotlin's `extra`. +pub fn script_properties(text: &str) -> BTreeMap { + let text = strip_comments(text); + let mut out = BTreeMap::new(); + for (index, word) in code_words(&text) { + if !starts_word(&text, index) { + continue; + } + let rest = text[index + word.len()..].trim_start(); + if word == "val" || word == "var" { + let (name, rest) = identifier(rest); + let Some(rest) = rest.trim_start().strip_prefix("by").map(str::trim_start) else { continue }; + let Some(rest) = rest.strip_prefix("extra").map(str::trim_start) else { continue }; + if let Some(rest) = rest.strip_prefix('(') + && let Some((value, used)) = literal(rest.trim_start()) + && rest.trim_start()[used..].trim_start().starts_with(')') + && !name.is_empty() + { + out.insert(name.to_owned(), value); + } + continue; + } + if word != "ext" && word != "extra" { + continue; + } + if let Some(block) = rest.strip_prefix('{') { + if let Some(end) = matching_brace(block) { + out.extend(assignments(&block[..end])); + } + } else if let Some(rest) = rest.strip_prefix('.') { + let (name, rest) = identifier(rest); + if let Some(value) = assigned_literal(rest) + && !name.is_empty() + { + out.insert(name.to_owned(), value); + } + } else if let Some(rest) = rest.strip_prefix('[') + && let Some((name, used)) = literal(rest.trim_start()) + && let Some(rest) = rest.trim_start()[used..].trim_start().strip_prefix(']') + && let Some(value) = assigned_literal(rest) + { + out.insert(name, value); + } + } + out +} + +pub(crate) fn file_properties(build_dir: &Path) -> BTreeMap { + let text = std::fs::read_to_string(build_dir.join("gradle.properties")).unwrap_or_default(); + text.lines() + .filter_map(|line| { + let line = line.trim(); + if line.starts_with(['#', '!']) { + return None; + } + let separator = line.find(|c: char| c == '=' || c == ':' || c.is_whitespace())?; + let (key, rest) = line.split_at(separator); + let value = rest.trim_start().trim_start_matches(['=', ':']).trim_start(); + (!key.is_empty()).then(|| (key.to_owned(), value.to_owned())) + }) + .collect() +} + +/// The build script of a project and, recursively, the scripts it applies with `apply from`, +/// read from disk: absolute path and text. Targets that do not exist are skipped. +pub fn scripts_of(root: &Path, project_dir: &Path, build_dir: &Path, build_file: &Path) -> Vec<(PathBuf, String)> { + const MAX_SCRIPTS: usize = 256; + const MAX_DEPTH: usize = 32; + let mut out = Vec::new(); + let mut seen = std::collections::HashSet::new(); + let first = normalize(&root.join(build_file)); + let Ok(identity) = dunce::canonicalize(&first) else { return out }; + seen.insert(identity); + let mut stack = vec![(first, 0)]; + while let Some((path, depth)) = stack.pop() { + let Ok(text) = std::fs::read_to_string(&path) else { continue }; + if depth < MAX_DEPTH { + let mut targets = Vec::new(); + for spec in applied_scripts(&text) { + if seen.len() >= MAX_SCRIPTS { + break; + } + if let Some(target) = resolve_script(&spec, &path, project_dir, build_dir) + && let Ok(identity) = dunce::canonicalize(&target) + && seen.insert(identity) + { + targets.push((target, depth + 1)); + } + } + stack.extend(targets.into_iter().rev()); + } + out.push((path, text)); + } + out +} + +/// Relative targets are project-scoped, with a lenient fallback beside the applying script. +fn resolve_script(spec: &str, script: &Path, project_dir: &Path, build_dir: &Path) -> Option { + let spec = spec.trim(); + if spec.contains("://") { + return None; + } + let properties = BTreeMap::from([ + ("rootDir".into(), build_dir.to_string_lossy().into_owned()), + ("rootProject.projectDir".into(), build_dir.to_string_lossy().into_owned()), + ("projectDir".into(), project_dir.to_string_lossy().into_owned()), + ]); + let spec = interpolate(spec, &properties); + if spec.contains('$') { + return None; + } + let path = Path::new(&spec); + if path.is_absolute() { + return path.is_file().then(|| normalize(path)); + } + let project_path = normalize(&project_dir.join(path)); + if project_path.is_file() { + return Some(project_path); + } + let path = normalize(&script.parent()?.join(path)); + path.is_file().then_some(path) +} + +pub(crate) fn project_build_dir(root: &Path, model: &GradleModel, project: &GradleProject) -> PathBuf { + let dir = project.build.as_ref().and_then(|build| model.projects.iter().find(|p| &p.path == build)); + root.join(dir.map_or(&model.dir, |p| &p.dir)) +} + +/// `2.8.9` for `${gsonVersion}` / `$gsonVersion` / `${ext.gsonVersion}`; unknown names and +/// expressions are left as written, so ranges and catalogs stay skipped. +fn interpolate(value: &str, properties: &BTreeMap) -> String { + if !value.contains('$') { + return value.to_owned(); + } + let mut out = String::with_capacity(value.len()); + let mut chars = value.chars().peekable(); + while let Some(c) = chars.next() { + if c != '$' { + out.push(c); + continue; + } + let braced = chars.peek() == Some(&'{'); + if braced { + chars.next(); + } + let mut name = String::new(); + if braced { + for n in chars.by_ref() { + if n == '}' { + break; + } + name.push(n); + } + } else { + while let Some(&n) = chars.peek() { + if n.is_alphanumeric() || n == '_' || n == '.' { + name.push(n); + chars.next(); + } else { + break; + } + } + } + let key = name.strip_prefix("ext.").or_else(|| name.strip_prefix("extra.")).unwrap_or(&name); + let bracket = name.strip_prefix("ext[").or_else(|| name.strip_prefix("extra[")).and_then(|rest| { + let (key, used) = literal(rest.trim_start())?; + (rest.trim_start()[used..].trim() == "]").then_some(key) + }); + match properties.get(bracket.as_deref().unwrap_or(key)) { + Some(v) if !name.is_empty() => out.push_str(v), + _ => { + out.push('$'); + if braced { + out.push('{'); + } + out.push_str(&name); + if braced { + out.push('}'); + } + } + } + } + out +} + +/// Whether `index` starts a whole word in `text` (not `reapply`, `my.apply`). +fn starts_word(text: &str, index: usize) -> bool { + text[..index].chars().next_back().is_none_or(|c| !(c.is_alphanumeric() || c == '_' || c == '.')) +} + +/// Identifiers outside quoted strings, with their byte positions. +fn code_words(text: &str) -> Vec<(usize, &str)> { + let mut out = Vec::new(); + let mut index = 0; + while index < text.len() { + let c = text[index..].chars().next().unwrap(); + if c == '"' || c == '\'' { + index += literal(&text[index..]).map_or(text.len() - index, |(_, used)| used); + } else if c.is_alphabetic() || c == '_' { + let (word, _) = identifier(&text[index..]); + out.push((index, word)); + index += word.len(); + } else { + index += c.len_utf8(); + } + } + out +} + +fn identifier(text: &str) -> (&str, &str) { + let end = text.find(|c: char| !(c.is_alphanumeric() || c == '_')).unwrap_or(text.len()); + text.split_at(end) +} + +fn assigned_literal(text: &str) -> Option { + let rest = text.trim_start().strip_prefix('=')?.trim_start(); + let (value, used) = literal(rest)?; + // Do not turn a computed value (`'1.' + suffix`) into a concrete version. + let tail = rest[used..].trim_start_matches([' ', '\t', '\r']); + (tail.is_empty() || tail.starts_with(['\n', ';', '}'])).then_some(value) +} + +/// The quoted string at the start of `s`, and how many bytes it spans. +fn literal(s: &str) -> Option<(String, usize)> { + let quote = s.chars().next().filter(|c| *c == '"' || *c == '\'')?; + let rest = &s[quote.len_utf8()..]; + let mut index = 0; + while index < rest.len() { + let c = rest[index..].chars().next()?; + if c == quote { + return Some((rest[..index].to_owned(), quote.len_utf8() + index + c.len_utf8())); + } + if c == '\\' { + index += c.len_utf8(); + index += rest[index..].chars().next()?.len_utf8(); + } else if quote == '"' && rest[index..].starts_with("${") { + index += 2; + index += matching_brace(&rest[index..])? + 1; + } else { + index += c.len_utf8(); + } + } + None +} + +/// Byte index of the `}` closing a `{` that was already consumed, ignoring quoted text. +fn matching_brace(text: &str) -> Option { + let mut depth = 1usize; + let mut index = 0; + while index < text.len() { + let c = text[index..].chars().next()?; + if c == '"' || c == '\'' { + index += literal(&text[index..])?.1; + continue; + } + if c == '{' { + depth += 1; + } else if c == '}' { + depth -= 1; + if depth == 0 { + return Some(index); + } + } + index += c.len_utf8(); + } + None +} + +/// `name = 'value'` pairs in `text`. +fn assignments(text: &str) -> Vec<(String, String)> { + statements(text) + .into_iter() + .filter_map(|statement| { + let (name, rest) = identifier(statement.trim_start()); + let value = assigned_literal(rest)?; + (!name.is_empty()).then(|| (name.to_owned(), value)) + }) + .collect() +} + /// Every settings and build script of the model (for change detection). pub fn build_files(root: &Path, model: &GradleModel) -> Vec { let dir = root.join(&model.dir); let mut out: Vec = SETTINGS_FILES.iter().map(|f| dir.join(f)).filter(|p| p.is_file()).collect(); out.extend(model.projects.iter().filter_map(|p| p.build_file.as_ref().map(|f| root.join(f)))); + // Scripts a build script applies are build files too: editing one can change the model. + for project in &model.projects { + let Some(file) = project.build_file.as_deref() else { continue }; + let build_dir = project_build_dir(root, model, project); + let properties = build_dir.join("gradle.properties"); + if properties.is_file() { + out.push(properties); + } + let applied = scripts_of(root, &root.join(&project.dir), &build_dir, Path::new(file)); + out.extend(applied.into_iter().skip(1).map(|(path, _)| path)); + } // The settings of each nested build (its root project's folder). for p in model.projects.iter().filter(|p| p.build.as_deref() == Some(p.path.as_str())) { let nested = root.join(&p.dir); @@ -624,6 +969,8 @@ pub fn build_files(root: &Path, model: &GradleModel) -> Vec { catalogs.sort(); out.extend(catalogs); } + out.sort(); + out.dedup(); out } @@ -808,7 +1155,7 @@ dependencies { classpath 'not:a:library' } "#; - let deps = script_dependencies(script); + let deps = script_dependencies(script, &BTreeMap::new()); let names: Vec = deps.iter().map(|(g, a, v)| format!("{g}:{a}:{v}")).collect(); assert_eq!( names, @@ -822,6 +1169,291 @@ dependencies { ); } + #[test] + fn applied_scripts_read_groovy_and_kotlin_forms() { + let script = r#" + apply plugin: 'java' + apply from: 'gradle/dependencies.gradle' + apply from: file('gradle/extra.gradle') + apply(from = "gradle/kotlin.gradle.kts") + apply(from = file("gradle/kotlin-file.gradle.kts")) + // apply from: 'commented.gradle' + reapply from: 'not-a-call.gradle' + "#; + assert_eq!( + applied_scripts(script), + [ + "gradle/dependencies.gradle", + "gradle/extra.gradle", + "gradle/kotlin.gradle.kts", + "gradle/kotlin-file.gradle.kts" + ] + ); + } + + #[test] + fn ext_values_fill_in_dependency_versions() { + let script = r#" + ext { + gsonVersion = '2.8.9' + junit = "4.13.2" + } + ext.logback = '1.4.14' + dependencies { + implementation "com.google.code.gson:gson:${gsonVersion}" + testImplementation "junit:junit:$junit" + implementation "ch.qos.logback:logback-classic:${ext.logback}" + implementation 'org.plain:literal:1.0' + implementation "org.x:unknown:${missing}" + } + "#; + let names: Vec = script_dependencies(script, &script_properties(script)) + .into_iter() + .map(|(g, a, v)| format!("{g}:{a}:{v}")) + .collect(); + assert_eq!( + names, + [ + "com.google.code.gson:gson:2.8.9", + "junit:junit:4.13.2", + "ch.qos.logback:logback-classic:1.4.14", + "org.plain:literal:1.0", + ] + ); + } + + #[test] + fn extra_properties_support_brackets_and_kotlin_delegates() { + for declaration in [ + "ext.x = '1.2'", + "ext { x = '1.2' }", + "ext[\"x\"] = '1.2'", + "extra[\"x\"] = \"1.2\"", + "val x by extra(\"1.2\")", + ] { + let script = format!("{declaration}\ndependencies {{ implementation \"g:a:${{x}}\" }}"); + assert_eq!( + script_dependencies(&script, &script_properties(&script)), + [("g".into(), "a".into(), "1.2".into())], + "{declaration}" + ); + } + } + + #[test] + fn parsers_ignore_apply_and_ext_inside_strings() { + let script = r#" + println "apply from: 'fake.gradle'" + println "ext.x = 'bogus'" + ext.real = '1' + "#; + assert!(applied_scripts(script).is_empty()); + assert_eq!(script_properties(script), BTreeMap::from([("real".into(), "1".into())])); + } + + #[test] + fn dependency_statements_preserve_interpolation_and_escaped_quotes() { + let script = r#"dependencies { implementation "g:a:${v}"; api(group = "g", name = "b", version = "${v}-RC1") }; dependencies { runtimeOnly 'g:c:2' }"#; + let properties = BTreeMap::from([("v".into(), "1.2".into())]); + assert_eq!( + script_dependencies(script, &properties), + [ + ("g".into(), "a".into(), "1.2".into()), + ("g".into(), "b".into(), "1.2-RC1".into()), + ("g".into(), "c".into(), "2".into()) + ] + ); + assert_eq!( + statements(r#"println "escaped\"; {still quoted}"; implementation 'g:a:1'"#), + [r#"println "escaped\"; {still quoted}""#, " implementation 'g:a:1'"] + ); + } + + #[test] + fn dependency_versions_skip_unresolved_names_and_expressions() { + let script = r#"dependencies { + implementation "g:missing:${unknown}" + implementation "g:expression:${1 + 2}" + implementation "g:cyclic:${cycle}" + implementation 'g:range:[1,2)' + implementation 'g:dynamic:1.+' + }"#; + let properties = BTreeMap::from([("cycle".into(), "${cycle}".into())]); + assert!(script_dependencies(script, &properties).is_empty()); + } + + #[test] + fn extra_property_references_support_qualified_and_bracket_access() { + let script = r#"dependencies { + implementation "g:a:${ext.x}" + implementation "g:b:${extra.x}" + implementation "g:c:${ext['x']}" + implementation "g:d:${extra["x"]}" + }"#; + let properties = BTreeMap::from([("x".into(), "1".into())]); + assert_eq!(script_dependencies(script, &properties).len(), 4); + } + + #[test] + fn applied_scripts_are_read_recursively_and_survive_cycles() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::create_dir_all(root.join("gradle")).unwrap(); + std::fs::write(root.join("build.gradle"), "apply from: 'gradle/deps.gradle'\n").unwrap(); + std::fs::write(root.join("gradle/deps.gradle"), "apply from: '$rootDir/gradle/more.gradle'\n").unwrap(); + std::fs::write(root.join("gradle/more.gradle"), "apply from: 'deps.gradle'\n").unwrap(); + + let scripts: Vec = scripts_of(root, root, root, Path::new("build.gradle")) + .into_iter() + .map(|(path, _)| path.strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/")) + .collect(); + assert_eq!(scripts, ["build.gradle", "gradle/deps.gradle", "gradle/more.gradle"]); + assert_eq!(build_files(root, &load(root).unwrap()).len(), 3); + } + + #[test] + fn applied_paths_prefer_the_project_directory() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::create_dir_all(root.join("gradle")).unwrap(); + std::fs::write(root.join("build.gradle"), "apply from: 'gradle/deps.gradle'").unwrap(); + std::fs::write(root.join("gradle/deps.gradle"), "apply from: 'versions.gradle'").unwrap(); + std::fs::write(root.join("versions.gradle"), "right").unwrap(); + std::fs::write(root.join("gradle/versions.gradle"), "wrong").unwrap(); + let scripts = scripts_of(root, root, root, Path::new("build.gradle")); + assert_eq!(scripts.len(), 3); + assert_eq!(scripts[2].1, "right"); + } + + #[test] + fn nested_applied_paths_are_relative_to_the_project_directory() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::create_dir_all(root.join("gradle")).unwrap(); + std::fs::write(root.join("build.gradle"), "apply from: 'gradle/a.gradle'").unwrap(); + std::fs::write(root.join("gradle/a.gradle"), "apply from: 'gradle/b.gradle'").unwrap(); + std::fs::write(root.join("gradle/b.gradle"), "").unwrap(); + let scripts = scripts_of(root, root, root, Path::new("build.gradle")); + assert_eq!(scripts.len(), 3); + assert_eq!(scripts[2].0, root.join("gradle/b.gradle")); + } + + #[test] + fn applied_paths_fall_back_to_the_applying_script_directory() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::create_dir_all(root.join("gradle")).unwrap(); + std::fs::write(root.join("build.gradle"), "apply from: 'gradle/a.gradle'").unwrap(); + std::fs::write(root.join("gradle/a.gradle"), "apply from: 'c.gradle'").unwrap(); + std::fs::write(root.join("gradle/c.gradle"), "").unwrap(); + let scripts = scripts_of(root, root, root, Path::new("build.gradle")); + assert_eq!(scripts.len(), 3); + assert_eq!(scripts[2].0, root.join("gradle/c.gradle")); + } + + #[test] + fn applied_paths_resolve_gradle_directory_variables() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let project = root.join("app"); + let script = project.join("gradle/deps.gradle"); + std::fs::create_dir_all(script.parent().unwrap()).unwrap(); + std::fs::write(root.join("shared.gradle"), "").unwrap(); + std::fs::write(project.join("local.gradle"), "").unwrap(); + for prefix in ["$rootDir", "${rootDir}", "$rootProject.projectDir", "${rootProject.projectDir}"] { + assert_eq!( + resolve_script(&format!("{prefix}/shared.gradle"), &script, &project, root), + Some(root.join("shared.gradle")), + "{prefix}" + ); + } + for prefix in ["$projectDir", "${projectDir}"] { + assert_eq!( + resolve_script(&format!("{prefix}/local.gradle"), &script, &project, root), + Some(project.join("local.gradle")), + "{prefix}" + ); + } + assert_eq!( + resolve_script(root.join("shared.gradle").to_str().unwrap(), &script, &project, root), + Some(root.join("shared.gradle")) + ); + } + + #[test] + fn applied_scripts_skip_urls_and_missing_files() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + // Even a local path resembling a URL must not be read. + if !cfg!(windows) { + for scheme in ["http", "https"] { + let local = root.join(format!("{scheme}:/example.com/deps.gradle")); + std::fs::create_dir_all(local.parent().unwrap()).unwrap(); + std::fs::write(local, "implementation 'g:remote:1'").unwrap(); + } + } + std::fs::write(root.join("build.gradle"), "apply from: 'http://example.com/deps.gradle'\napply(from = \"https://example.com/deps.gradle\")\napply from: 'missing.gradle'").unwrap(); + assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 1); + } + + #[test] + fn applied_scripts_deduplicate_self_application_and_path_aliases() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::create_dir_all(root.join("gradle")).unwrap(); + std::fs::write( + root.join("build.gradle"), + "apply from: 'build.gradle'\napply from: 'gradle/../build.gradle'\napply from: 'gradle/a.gradle'", + ) + .unwrap(); + std::fs::write(root.join("gradle/a.gradle"), "apply from: '../build.gradle'").unwrap(); + assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 2); + } + + #[cfg(unix)] + #[test] + fn applied_scripts_deduplicate_symlink_cycles() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::write(root.join("build.gradle"), "apply from: 'alias/build.gradle'").unwrap(); + std::os::unix::fs::symlink(root, root.join("alias")).unwrap(); + assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 1); + } + + #[test] + fn applied_script_traversal_bounds_depth_and_total_files() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + for i in 0..300 { + std::fs::write(root.join(format!("{i}.gradle")), format!("apply from: '{}.gradle'", i + 1)).unwrap(); + } + let chain = scripts_of(root, root, root, Path::new("0.gradle")); + assert_eq!(chain.len(), 33); + let fanout: String = (0..300).map(|i| format!("apply from: '{i}.gradle'\n")).collect(); + std::fs::write(root.join("build.gradle"), fanout).unwrap(); + assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 256); + } + + #[test] + fn applied_scripts_use_their_composite_build_root() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::create_dir_all(root.join("included/app")).unwrap(); + std::fs::write(root.join("build.gradle"), "").unwrap(); + std::fs::write(root.join("included/build.gradle"), "").unwrap(); + std::fs::write(root.join("included/app/build.gradle"), "apply from: \"${rootDir}/deps.gradle\"").unwrap(); + std::fs::write(root.join("included/deps.gradle"), "implementation 'g:included:1'").unwrap(); + let mut model = load(root).unwrap(); + for (path, dir) in [(":included", "included"), (":included:app", "included/app")] { + let mut project = project_at(root, path.into(), path.into(), &root.join(dir)); + project.build = Some(":included".into()); + model.projects.push(project); + } + assert!(build_files(root, &model).contains(&root.join("included/deps.gradle"))); + let model = crate::BuildModel { maven: None, gradle: vec![model] }; + assert_eq!(crate::dependency_coordinates(root, &model)[0].artifact_id, "included"); + } + use super::*; #[test] diff --git a/crates/ide-build/src/lib.rs b/crates/ide-build/src/lib.rs index 3c436dec1..ebc8e2bf8 100644 --- a/crates/ide-build/src/lib.rs +++ b/crates/ide-build/src/lib.rs @@ -147,7 +147,8 @@ pub struct DependencyCoordinate { /// The external libraries of `model`, sorted and deduplicated: Maven dependencies /// (with the transitive ones the last sync read), except the build's own modules, -/// `pom` imports and `system` scope; dependencies declared in the Gradle build scripts. +/// `pom` imports and `system` scope; dependencies declared in the Gradle build scripts, +/// including the scripts they pull in with `apply from`. pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec { let mut out = Vec::new(); if let Some(m) = &model.maven { @@ -177,11 +178,26 @@ pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec = dependency_coordinates(root, &model) + .into_iter() + .map(|c| format!("{}:{}:{}", c.group_id, c.artifact_id, c.version)) + .collect(); + assert_eq!(names, ["com.google.code.gson:gson:2.8.9"]); + } + + #[test] + fn gradle_properties_override_script_extra_values() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::write(root.join("build.gradle"), "ext.v = 'wrong'\napply from: 'deps.gradle'").unwrap(); + std::fs::write( + root.join("deps.gradle"), + "ext.v = 'alsoWrong'\nimplementation \"g:a:${v}\"\nimplementation \"g:b:${onlyInProperties}\"", + ) + .unwrap(); + std::fs::write(root.join("gradle.properties"), "# versions\nv = 1.2\nonlyInProperties: 2.3\n").unwrap(); + let model = BuildModel { maven: None, gradle: gradle::load(root).into_iter().collect() }; + let versions: Vec<_> = dependency_coordinates(root, &model).into_iter().map(|c| c.version).collect(); + assert_eq!(versions, ["1.2", "2.3"]); + } } diff --git a/crates/ide-build/src/sync.rs b/crates/ide-build/src/sync.rs index 13336817d..d97bba563 100644 --- a/crates/ide-build/src/sync.rs +++ b/crates/ide-build/src/sync.rs @@ -818,6 +818,40 @@ mod tests { assert!(m.stale, "gradle.properties is watched"); } + #[test] + fn applied_script_edits_invalidate_the_cached_gradle_model() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("project"); + let cache_dir = tmp.path().join("cache"); + std::fs::create_dir_all(root.join("gradle")).unwrap(); + std::fs::write(root.join("build.gradle"), "apply from: 'gradle/dependencies.gradle'").unwrap(); + let applied = root.join("gradle/dependencies.gradle"); + std::fs::write(&applied, "implementation 'g:a:1'").unwrap(); + let model = gradle::load(&root).unwrap(); + let cache = GradleCache { + hash: hash_gradle_files(&gradle::build_files(&root, &model)), + projects: vec![ReportedProject { + path: ":".into(), + dir: root.to_string_lossy().into_owned(), + ..Default::default() + }], + ..Default::default() + }; + write_cache(&cache_dir, GRADLE_CACHE, &cache).unwrap(); + let stale = || { + let mut model = gradle::load(&root).unwrap(); + apply_gradle_cache(&root, &cache_dir, &mut model); + model.stale + }; + assert!(!stale()); + std::fs::write(&applied, "// comment\nimplementation 'g:a:1'\n").unwrap(); + assert!(!stale()); + std::fs::write(&applied, "implementation 'g:a:2'").unwrap(); + assert!(stale()); + std::fs::remove_file(&applied).unwrap(); + assert!(stale()); + } + #[test] fn sync_reads_verbose_trees_with_a_pinned_plugin_unless_offline() { let online = maven_sync_goals(Path::new("e.xml"), false); diff --git a/src/features/build-tools/index.ts b/src/features/build-tools/index.ts index 5703f6af7..0b6a9420f 100644 --- a/src/features/build-tools/index.ts +++ b/src/features/build-tools/index.ts @@ -6,6 +6,7 @@ import { onEditorEvent, registerEditorContribution } from "@/editor/api"; import { executeAction, registerActions } from "@/platform/actions"; import { setContext } from "@/platform/context"; +import { isGradleBuildInput } from "@/platform/buildFiles"; import { onIdeEvent, type BuildModel, type BuildTool } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { @@ -58,8 +59,8 @@ export const MAVEN_WHEN = "projectOpen && build.maven"; export const GRADLE_WHEN = "projectOpen && build.gradle"; /** Build files whose creation / change / removal re-reads the model. */ -const BUILD_FILE = - /(?:^|\/)(?:pom\.xml|(?:build|settings)\.gradle(?:\.kts)?|mvnw(?:\.cmd)?|gradlew(?:\.bat)?|gradle\.properties|gradle-wrapper\.properties|[^/]+\.versions\.toml)$/; +const isBuildFile = (path: string) => + isGradleBuildInput(path) || /(?:^|[\\/])(?:pom\.xml|mvnw(?:\.cmd)?|gradlew(?:\.bat)?)$/.test(path); function expandAll(tool: BuildTool) { const { model, configs } = useBuild.getState(); @@ -234,10 +235,10 @@ export function activate(): () => void { if (s.project) void loadModel(); }), onEditorEvent((e) => { - if (e.type === "docSaved" && BUILD_FILE.test(e.path)) noteIdeSave(e.path); + if (e.type === "docSaved" && isBuildFile(e.path)) noteIdeSave(e.path); }), onIdeEvent("fsChanged", (e) => { - const changed = [...e.created, ...e.modified, ...e.removed].filter((p) => BUILD_FILE.test(p)); + const changed = [...e.created, ...e.modified, ...e.removed].filter(isBuildFile); if (!changed.length) return; // Changes the IDE's own saves didn't make came from outside (a VCS update, another tool), // also for a build file open in a tab. diff --git a/src/features/build-tools/linkage.test.tsx b/src/features/build-tools/linkage.test.tsx index 2006e1c7c..3b59d5806 100644 --- a/src/features/build-tools/linkage.test.tsx +++ b/src/features/build-tools/linkage.test.tsx @@ -3,6 +3,7 @@ import { act, fireEvent, render, screen } from "@testing-library/react"; import { executeAction, registerAction, allActions, isActionEnabled } from "@/platform/actions"; import { setContext } from "@/platform/context"; import type { BuildModel, GradleModel, ProjectInfo, RunConfig } from "@/platform/ipc"; +import { emitLocalIdeEvent } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { GradleView, MavenView } from "./BuildToolView"; import { activate } from "./index"; @@ -112,6 +113,22 @@ afterEach(() => { setContext("projectOpen", false); }); +it("re-reads the build model when an applied Groovy or Kotlin script changes", async () => { + vi.useFakeTimers(); + try { + for (const path of ["/p/gradle/dependencies.gradle", "/p/gradle/dependencies.gradle.kts"]) { + const calls = mocks.model.mock.calls.length; + await act(async () => { + emitLocalIdeEvent({ type: "fsChanged", created: [], modified: [path], removed: [] }); + await vi.advanceTimersByTimeAsync(300); + }); + expect(mocks.model).toHaveBeenCalledTimes(calls + 1); + } + } finally { + vi.useRealTimers(); + } +}); + it("offers unlink on a root's context menu and persists a confirmed project setting", async () => { render(); fireEvent.contextMenu(screen.getByText("project-a")); diff --git a/src/features/libraries/index.ts b/src/features/libraries/index.ts index 0f7d7c52e..2cedfa775 100644 --- a/src/features/libraries/index.ts +++ b/src/features/libraries/index.ts @@ -9,6 +9,7 @@ import { Channel } from "@tauri-apps/api/core"; import { commands, type LibraryGroup, type RequestId } from "@/bindings"; import { mergeLibraryGroups } from "@/platform/libraries"; +import { isGradleBuildInput } from "@/platform/buildFiles"; import { registerActions } from "@/platform/actions"; import { setLibraryRoots } from "@/editor/api"; import { call, isCancelled, isTauri, nextRequestId, onIdeEvent } from "@/platform/ipc"; @@ -73,7 +74,7 @@ export function touchesManifest(paths: readonly string[]): boolean { return paths.some( (p) => !/[\\/]node_modules[\\/]/.test(p) && - MANIFESTS.has(p.slice(Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\")) + 1)), + (isGradleBuildInput(p) || MANIFESTS.has(p.slice(Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\")) + 1))), ); } diff --git a/src/features/libraries/libraries.test.ts b/src/features/libraries/libraries.test.ts index 86a41eaf6..2157a43b1 100644 --- a/src/features/libraries/libraries.test.ts +++ b/src/features/libraries/libraries.test.ts @@ -78,6 +78,13 @@ describe("External Libraries", () => { expect(touchesManifest(["/p/src/package.json.bak", "/p/README.md"])).toBe(false); }); + it("refreshes when an applied Gradle script or properties file changes", () => { + expect(touchesManifest(["/p/gradle/dependencies.gradle"])).toBe(true); + expect(touchesManifest(["C:\\p\\gradle\\dependencies.gradle.kts"])).toBe(true); + expect(touchesManifest(["/p/gradle.properties"])).toBe(true); + expect(touchesManifest(["/p/node_modules/tool/dependencies.gradle"])).toBe(false); + }); + it("marks files below library roots for read-only editors", () => { dispose = setLibraryRoots("test", ["/py/lib/python3.12"]); expect(isLibraryPath("/py/lib/python3.12/os.py")).toBe(true); diff --git a/src/platform/buildFiles.test.ts b/src/platform/buildFiles.test.ts new file mode 100644 index 000000000..f08ee6b8f --- /dev/null +++ b/src/platform/buildFiles.test.ts @@ -0,0 +1,18 @@ +import { expect, it } from "vitest"; +import { isGradleBuildInput } from "./buildFiles"; + +it("recognizes applied scripts and Gradle properties for build and library refresh", () => { + for (const path of [ + "/p/gradle/dependencies.gradle", + "/p/gradle/dependencies.gradle.kts", + "C:\\p\\gradle\\dependencies.gradle", + "/p/gradle.properties", + "/p/gradle/wrapper/gradle-wrapper.properties", + "/p/gradle/libs.versions.toml", + ]) { + expect(isGradleBuildInput(path), path).toBe(true); + } + for (const path of ["/p/dependencies.gradle.bak", "/p/src/main.kt", "/p/README.md"]) { + expect(isGradleBuildInput(path), path).toBe(false); + } +}); diff --git a/src/platform/buildFiles.ts b/src/platform/buildFiles.ts new file mode 100644 index 000000000..3929b71f9 --- /dev/null +++ b/src/platform/buildFiles.ts @@ -0,0 +1,6 @@ +/** Gradle scripts and static inputs whose edits can change the build model or libraries. */ +export function isGradleBuildInput(path: string): boolean { + return /(?:^|[\\/])(?:[^\\/]+\.gradle(?:\.kts)?|gradle\.properties|gradle-wrapper\.properties|[^\\/]+\.versions\.toml)$/.test( + path, + ); +}