дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
fix(gradle): read dependencies from scripts a build applies (#2277)
Dependencies declared in a script pulled in with `apply from:` never reached External Libraries, and `"g🅰️${v}"` versions were dropped because statement splitting at `{` cut them in half. The scan now follows `apply from` recursively (Groovy and Kotlin DSL; `$rootDir`, `$projectDir`, `rootProject.projectDir`; relative paths from the project directory, then beside the applying script; URLs and missing files skipped; cycles and aliases cut by canonical path, bounded at 256 scripts and 32 levels). It fills `ext`/`extra` values (`ext.x`, `ext { }`, `ext["x"]`, `extra["x"]`, `val x by extra(...)`) into `${...}` versions with gradle.properties taking precedence, and counts applied scripts as build inputs, so editing one invalidates the cached model and refreshes the Build tool window and External Libraries. Co-authored-by: zmh69695328 <[email protected]>
Цей коміт міститься в:
1 джерело
e86134ce83
коміт
dfa610beb0
9 files changed
+787
-20
No files matched your search
@@ -109,11 +109,11 @@ fn strip_comments(text: &str) -> String {
|
||||
/// Quoted string literals in `s`.
|
||||
fn literals(s: &str) -> Vec<String> {
|
||||
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<String, String>) -> 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<String> {
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
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<PathBuf> {
|
||||
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, String>) -> 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<String> {
|
||||
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<usize> {
|
||||
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<PathBuf> {
|
||||
let dir = root.join(&model.dir);
|
||||
let mut out: Vec<PathBuf> = 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<PathBuf> {
|
||||
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<String> = 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<String> = 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<String> = 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]
|
||||
|
||||
@@ -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<DependencyCoordinate> {
|
||||
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
|
||||
}
|
||||
}
|
||||
for g in &model.gradle {
|
||||
for script in g.projects.iter().filter_map(|p| p.build_file.as_ref()) {
|
||||
let Ok(text) = std::fs::read_to_string(root.join(script)) else { continue };
|
||||
out.extend(gradle::script_dependencies(&text).into_iter().map(|(group_id, artifact_id, version)| {
|
||||
DependencyCoordinate { gradle: true, group_id, artifact_id, version }
|
||||
}));
|
||||
for p in &g.projects {
|
||||
let Some(build_file) = p.build_file.as_deref() else { continue };
|
||||
let build_dir = gradle::project_build_dir(root, g, p);
|
||||
let scripts = gradle::scripts_of(root, &root.join(&p.dir), &build_dir, Path::new(build_file));
|
||||
// The script's own `ext` values win over the scripts it applies.
|
||||
let mut properties = std::collections::BTreeMap::new();
|
||||
for (_, text) in scripts.iter().rev() {
|
||||
properties.extend(gradle::script_properties(text));
|
||||
}
|
||||
properties.extend(gradle::file_properties(&build_dir));
|
||||
for (_, text) in &scripts {
|
||||
out.extend(gradle::script_dependencies(text, &properties).into_iter().map(
|
||||
|(group_id, artifact_id, version)| DependencyCoordinate {
|
||||
gradle: true,
|
||||
group_id,
|
||||
artifact_id,
|
||||
version,
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
@@ -312,4 +328,39 @@ mod tests {
|
||||
.collect();
|
||||
assert_eq!(names, ["M org.b:y:2", "M org.c:z:3", "G org.a:x:1.0"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_coordinates_read_scripts_brought_in_by_apply_from() {
|
||||
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/dependencies.gradle'\n").unwrap();
|
||||
std::fs::write(
|
||||
root.join("gradle/dependencies.gradle"),
|
||||
"ext { gson = '2.8.9' }\ndependencies { implementation \"com.google.code.gson:gson:${gson}\" }\n",
|
||||
)
|
||||
.unwrap();
|
||||
let model = BuildModel { maven: None, gradle: gradle::load(root).into_iter().collect() };
|
||||
let names: Vec<String> = 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"]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(<MavenView />);
|
||||
fireEvent.contextMenu(screen.getByText("project-a"));
|
||||
|
||||
@@ -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))),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача