From 7a0f60377ccbf4ab6c978da9d8bf77ea57b45238 Mon Sep 17 00:00:00 2001
From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Date: Thu, 5 Nov 2026 16:21:33 +0800
Subject: [PATCH] fix(tomcat): update web resources from the folders the WAR is
built from (#1508)
Updating a Tomcat deployment took web resources only from src/main/webapp, so JSPs under WebContent (maven-war-plugin webResources) never reached it. It now reads warSourceDirectory and webResources from the pom, or webAppDirName / webAppDirectory from the Gradle build.
---
crates/ide-run/src/tomcat.rs | 139 ++++++++++++++++++++++++++++++++++-
1 file changed, 135 insertions(+), 4 deletions(-)
diff --git a/crates/ide-run/src/tomcat.rs b/crates/ide-run/src/tomcat.rs
index 4eb198b3..ab49e617 100644
--- a/crates/ide-run/src/tomcat.rs
+++ b/crates/ide-run/src/tomcat.rs
@@ -204,7 +204,7 @@ pub fn explode(war: &Path, destination: &Path, resources_only: bool) -> IdeResul
}
/// Updates the exploded artifact deployment from the module's own class output directories and
-/// its web resources (`src/main/webapp`), file by file and only what changed, instead of
+/// its web resources ([`web_sources`]), file by file and only what changed, instead of
/// rebuilding and re-exploding the WAR. `module` and `class_paths` are absolute.
/// `Ok(false)`: not possible this way (not deployed from the artifact yet, the module's output
/// isn't on the classpath, or a module it depends on was compiled since the last update, whose
@@ -232,9 +232,10 @@ pub fn sync_outputs(base: &Path, module: &Path, class_paths: &[PathBuf], resourc
for dir in own {
sync_tree(dir, dir, &classes, Path::new("WEB-INF/classes"), resources_only, &mut current)?;
}
- let webapp = module.join("src/main/webapp");
- if webapp.is_dir() {
- sync_tree(&webapp, &webapp, &dest, Path::new(""), resources_only, &mut current)?;
+ for (dir, target) in web_sources(module) {
+ if dir.is_dir() {
+ sync_tree(&dir, &dir, &dest.join(&target), &target, resources_only, &mut current)?;
+ }
}
for rel in previous.iter().filter(|p| !current.contains(*p)) {
let path = dest.join(rel);
@@ -246,6 +247,59 @@ pub fn sync_outputs(base: &Path, module: &Path, class_paths: &[PathBuf], resourc
Ok(true)
}
+/// The folders whose files the WAR puts at its root (or under the second path): the web app
+/// folder (`src/main/webapp`, maven-war-plugin's `warSourceDirectory`, Gradle's
+/// `webAppDirName` / `webAppDirectory`), then maven-war-plugin's `webResources`.
+pub fn web_sources(module: &Path) -> Vec<(PathBuf, PathBuf)> {
+ let rel = |dir: &str| {
+ let dir = ["${project.basedir}/", "${basedir}/"].iter().find_map(|p| dir.strip_prefix(p)).unwrap_or(dir);
+ module.join(dir.trim())
+ };
+ if let Ok(pom) = fs::read_to_string(module.join("pom.xml"))
+ && let Some(war) = war_plugin(&pom)
+ {
+ let mut out = vec![(rel(tag(war, "warSourceDirectory").unwrap_or("src/main/webapp")), PathBuf::new())];
+ let mut rest = tag_block(war, "webResources").unwrap_or_default();
+ while let Some(start) = rest.find("") {
+ let end = rest[start..].find("").map_or(rest.len(), |e| start + e);
+ let resource = &rest[start..end];
+ if let Some(dir) = tag(resource, "directory") {
+ out.push((rel(dir), PathBuf::from(tag(resource, "targetPath").unwrap_or_default())));
+ }
+ rest = &rest[end..];
+ }
+ return out;
+ }
+ let gradle = ["build.gradle", "build.gradle.kts"].iter().find_map(|f| fs::read_to_string(module.join(f)).ok());
+ let web_app_dir = gradle.as_deref().and_then(|text| {
+ text.lines()
+ .filter(|l| !l.trim_start().starts_with("//"))
+ .find(|l| l.contains("webAppDirName") || l.contains("webAppDirectory"))
+ .and_then(|l| l.split(['"', '\'']).nth(1).map(str::to_owned))
+ });
+ vec![(rel(web_app_dir.as_deref().unwrap_or("src/main/webapp")), PathBuf::new())]
+}
+
+/// The `` element of maven-war-plugin in `pom`, if it is configured there.
+fn war_plugin(pom: &str) -> Option<&str> {
+ let at = pom.find("maven-war-plugin")?;
+ let start = pom[..at].rfind("")?;
+ let end = pom[at..].find("").map_or(pom.len(), |e| at + e);
+ Some(&pom[start..end])
+}
+
+/// The text of the first `` element in `xml`.
+fn tag<'a>(xml: &'a str, name: &str) -> Option<&'a str> {
+ tag_block(xml, name).map(str::trim).filter(|t| !t.is_empty())
+}
+
+fn tag_block<'a>(xml: &'a str, name: &str) -> Option<&'a str> {
+ let open = format!("<{name}>");
+ let start = xml.find(&open)? + open.len();
+ let end = start + xml[start..].find(&format!("{name}>"))?;
+ Some(&xml[start..end])
+}
+
fn modified_since(dir: &Path, since: std::time::SystemTime) -> bool {
let Ok(entries) = fs::read_dir(dir) else { return false };
entries.flatten().any(|e| match e.file_type() {
@@ -574,6 +628,83 @@ mod tests {
fs::File::create(dep.join("Core.class")).unwrap().set_modified(later).unwrap();
assert!(!sync_outputs(&base, &module, &paths, false).unwrap());
}
+ #[test]
+ fn web_sources_follow_the_war_plugin_and_gradle_settings() {
+ let tmp = tempfile::tempdir().unwrap();
+ let module = tmp.path();
+ let rel = |sources: Vec<(PathBuf, PathBuf)>| -> Vec<(String, String)> {
+ sources
+ .into_iter()
+ .map(|(d, t)| {
+ let d = d.strip_prefix(module).unwrap().to_string_lossy().replace('\\', "/");
+ (d, t.to_string_lossy().into_owned())
+ })
+ .collect()
+ };
+ assert_eq!(rel(web_sources(module)), [("src/main/webapp".to_owned(), String::new())]);
+
+ // An Eclipse-style layout: the JSPs come in through webResources.
+ fs::write(
+ module.join("pom.xml"),
+ r#"
+ maven-compiler-plugin
+
+ org.apache.maven.plugins
+ maven-war-plugin
+
+ ${basedir}/web
+
+ WebContent**/*
+ extrastatic
+
+
+
+ "#,
+ )
+ .unwrap();
+ assert_eq!(
+ rel(web_sources(module)),
+ [
+ ("web".to_owned(), String::new()),
+ ("WebContent".to_owned(), String::new()),
+ ("extra".to_owned(), "static".to_owned())
+ ]
+ );
+
+ fs::remove_file(module.join("pom.xml")).unwrap();
+ fs::write(
+ module.join("build.gradle"),
+ "plugins { id 'war' }\n// webAppDirName = 'old'\nwebAppDirName = 'WebContent'\n",
+ )
+ .unwrap();
+ assert_eq!(rel(web_sources(module)), [("WebContent".to_owned(), String::new())]);
+ }
+
+ #[test]
+ fn updates_web_resources_outside_src_main_webapp() {
+ let tmp = tempfile::tempdir().unwrap();
+ let root = tmp.path();
+ let (base, module) = (root.join("base"), root.join("web"));
+ let (dest, out) = (base.join("deployment"), module.join("target/classes"));
+ for dir in [dest.join("WEB-INF/classes"), out.clone(), module.join("WebContent/jsp")] {
+ fs::create_dir_all(dir).unwrap();
+ }
+ fs::write(
+ module.join("pom.xml"),
+ "maven-war-plugin\
+ WebContent",
+ )
+ .unwrap();
+ fs::write(dest.join("index.jsp"), "old").unwrap();
+ fs::write(module.join("WebContent/jsp/list.jsp"), "new").unwrap();
+ fs::write(base.join("artifact-path.txt"), "web.war").unwrap();
+
+ assert!(sync_outputs(&base, &module, std::slice::from_ref(&out), true).unwrap());
+ assert_eq!(fs::read_to_string(dest.join("jsp/list.jsp")).unwrap(), "new");
+ // Files the WAR brought that no source here maps to stay.
+ assert_eq!(fs::read_to_string(dest.join("index.jsp")).unwrap(), "old");
+ }
+
#[test]
fn deployment_errors_identify_the_checked_path() {
let tmp = tempfile::tempdir().unwrap();