This is an automated email from the ASF dual-hosted git repository.

hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new 34c9db16a9 Issue #2402 : Support ~ (tilde) at start of path to resolve 
${user.home} (#8256)
34c9db16a9 is described below

commit 34c9db16a9ca1986a9dfcd909c9584201275e115
Author: Matt Casters <[email protected]>
AuthorDate: Fri Sep 4 15:44:30 2026 +0200

    Issue #2402 : Support ~ (tilde) at start of path to resolve ${user.home} 
(#8256)
    
    - HopVfs: Add resolveHomeDirectory(path, variables) and 
resolveHomeDirectory(path)
      to resolve ~ (bare ~), ~/... and ~\... (Windows backslash) as well as 
file://~...
      to ${user.home}, while preserving non-start occurrences and non-separator 
suffixes.
    - HopVfs: Update resolveWith() to expand leading tildes before scheme 
checking,
      and update isAbsolutePath() to recognize tilde paths.
    - hop-misc-projects: Recognize tilde paths in 
PathVariableReplacer.isCandidatePathValue()
      and ProjectsMetadataExporter.resolveExportFilename().
    - hop-misc-git: Delegate CaseInsensitiveIgnores.replaceUserHome() to 
HopVfs.resolveHomeDirectory().
    - Documentation: Update vfs.adoc, variables.adoc, and 
projects-environments.adoc with
      tilde home directory path usage and platform examples.
    - Unit tests: Add test coverage in HopVfsTest and PathVariableReplacerTest.
---
 .../main/java/org/apache/hop/core/vfs/HopVfs.java  | 91 +++++++++++++++++++++-
 .../java/org/apache/hop/core/vfs/HopVfsTest.java   | 70 +++++++++++++++++
 .../ROOT/pages/projects/projects-environments.adoc |  4 +-
 .../modules/ROOT/pages/variables.adoc              | 10 +++
 docs/hop-user-manual/modules/ROOT/pages/vfs.adoc   | 11 +++
 .../hop/git/model/CaseInsensitiveIgnores.java      |  6 +-
 .../hop/projects/util/PathVariableReplacer.java    |  5 +-
 .../projects/util/ProjectsMetadataExporter.java    |  7 +-
 .../projects/util/PathVariableReplacerTest.java    | 13 ++++
 9 files changed, 202 insertions(+), 15 deletions(-)

diff --git a/core/src/main/java/org/apache/hop/core/vfs/HopVfs.java 
b/core/src/main/java/org/apache/hop/core/vfs/HopVfs.java
index 4787e127d3..36d3a3c58f 100644
--- a/core/src/main/java/org/apache/hop/core/vfs/HopVfs.java
+++ b/core/src/main/java/org/apache/hop/core/vfs/HopVfs.java
@@ -356,20 +356,94 @@ public class HopVfs {
    */
   public static FileObject getFileObject(String vfsFilename, IVariables 
variables)
       throws HopFileException {
-    return resolveWith(vfsFilename, getFileSystemManager(variables));
+    return resolveWith(vfsFilename, getFileSystemManager(variables), 
variables);
   }
 
   public static synchronized FileObject getFileObject(String vfsFilename) 
throws HopFileException {
     // Nothing to go on but the thread: the namespace of the execution running 
on it, if any.
     HopVfsNamespace namespace = HopVfsNamespaces.getCurrent();
     return resolveWith(
-        vfsFilename, namespace == null ? getFileSystemManager() : 
namespace.getFileSystemManager());
+        vfsFilename,
+        namespace == null ? getFileSystemManager() : 
namespace.getFileSystemManager(),
+        null);
   }
 
-  private static FileObject resolveWith(String vfsFilename, 
DefaultFileSystemManager fsManager)
+  /**
+   * Resolves paths that start with {@code ~} (tilde) to the user's home 
directory.
+   *
+   * <p>The tilde character is recognized only at the start of a path (e.g. 
{@code ~}, {@code
+   * ~/path}, {@code ~\path} on Windows, or prefixed with {@code file://~} or 
{@code file:~}). A
+   * tilde elsewhere in a path (e.g. {@code /tmp/~} or {@code foo~bar}) or a 
tilde followed by
+   * non-separator characters (e.g. {@code ~username} or {@code ~temp}) is not 
replaced.
+   *
+   * @param path the path to resolve
+   * @param variables optional variables to look up {@code user.home} from; if 
null or unset, falls
+   *     back to {@code System.getProperty("user.home")}
+   * @return the path with leading tilde expanded, or the original path if no 
tilde prefix applies
+   */
+  public static String resolveHomeDirectory(String path, IVariables variables) 
{
+    if (path == null || path.isEmpty()) {
+      return path;
+    }
+    String prefix = "";
+    String remaining = path;
+    if (remaining.startsWith("file://")) {
+      prefix = "file://";
+      remaining = remaining.substring("file://".length());
+    } else if (remaining.startsWith("file:")) {
+      prefix = "file:";
+      remaining = remaining.substring("file:".length());
+    }
+
+    if (remaining.equals("~") || remaining.startsWith("~/") || 
remaining.startsWith("~\\")) {
+      String userHome = null;
+      if (variables != null) {
+        userHome = variables.getVariable("user.home");
+      }
+      if (StringUtils.isEmpty(userHome)) {
+        userHome = System.getProperty("user.home");
+      }
+      if (userHome != null) {
+        // Strip trailing slash/backslash from userHome so appending remainder 
does not duplicate it
+        while (userHome.length() > 1 && (userHome.endsWith("/") || 
userHome.endsWith("\\"))) {
+          userHome = userHome.substring(0, userHome.length() - 1);
+        }
+        if (remaining.equals("~")) {
+          remaining = userHome;
+        } else {
+          remaining = userHome + remaining.substring(1);
+        }
+        if (!prefix.isEmpty()) {
+          if (prefix.equals("file://") && !remaining.startsWith("/")) {
+            return prefix + "/" + remaining;
+          }
+          return prefix + remaining;
+        }
+        return remaining;
+      }
+    }
+    return path;
+  }
+
+  /**
+   * Resolves paths that start with {@code ~} (tilde) to the user's home 
directory using {@code
+   * System.getProperty("user.home")}.
+   *
+   * @param path the path to resolve
+   * @return the path with leading tilde expanded, or the original path if no 
tilde prefix applies
+   * @see #resolveHomeDirectory(String, IVariables)
+   */
+  public static String resolveHomeDirectory(String path) {
+    return resolveHomeDirectory(path, null);
+  }
+
+  private static FileObject resolveWith(
+      String vfsFilename, DefaultFileSystemManager fsManager, IVariables 
variables)
       throws HopFileException {
 
     try {
+      vfsFilename = resolveHomeDirectory(vfsFilename, variables);
+
       // We have one problem with VFS: if the file is in a subdirectory of the 
current one:
       // somedir/somefile
       // In that case, VFS doesn't parse the file correctly.
@@ -785,6 +859,7 @@ public class HopVfs {
    * prepended. This recognises:
    *
    * <ul>
+   *   <li>Tilde paths pointing to user home ({@code ~}, {@code ~/path}, 
{@code ~\path})
    *   <li>VFS URIs with a scheme, e.g. {@code file:///...}, {@code s3://...}, 
{@code hdfs://...}
    *   <li>POSIX absolute paths ({@code /...})
    *   <li>Windows UNC paths ({@code \\host\share})
@@ -798,6 +873,16 @@ public class HopVfs {
     if (filename == null || filename.isEmpty()) {
       return false;
     }
+    String stripped = filename;
+    if (stripped.startsWith("file://")) {
+      stripped = stripped.substring("file://".length());
+    } else if (stripped.startsWith("file:")) {
+      stripped = stripped.substring("file:".length());
+    }
+    // A path starting with tilde (home directory): ~, ~/, ~\
+    if (stripped.equals("~") || stripped.startsWith("~/") || 
stripped.startsWith("~\\")) {
+      return true;
+    }
     // A VFS URI with a scheme, e.g. file:///, s3://, hdfs://, ...
     if (filename.contains("://")) {
       return true;
diff --git a/core/src/test/java/org/apache/hop/core/vfs/HopVfsTest.java 
b/core/src/test/java/org/apache/hop/core/vfs/HopVfsTest.java
index 1087d40d98..79d5a59cde 100644
--- a/core/src/test/java/org/apache/hop/core/vfs/HopVfsTest.java
+++ b/core/src/test/java/org/apache/hop/core/vfs/HopVfsTest.java
@@ -68,6 +68,12 @@ class HopVfsTest {
     // VFS URIs with a scheme
     assertTrue(HopVfs.isAbsolutePath("file:///home/me/test.hpl"));
     assertTrue(HopVfs.isAbsolutePath("s3://bucket/test.hpl"));
+    // Tilde home directory paths (POSIX, Windows backslash, bare ~, file://~)
+    assertTrue(HopVfs.isAbsolutePath("~"));
+    assertTrue(HopVfs.isAbsolutePath("~/test.hpl"));
+    assertTrue(HopVfs.isAbsolutePath("~\\test.hpl"));
+    assertTrue(HopVfs.isAbsolutePath("file://~/test.hpl"));
+    assertTrue(HopVfs.isAbsolutePath("file:~/test.hpl"));
   }
 
   @Test
@@ -79,6 +85,8 @@ class HopVfsTest {
     assertFalse(HopVfs.isAbsolutePath("sub/test.hpl"));
     // Windows drive-relative (no separator after the colon) is NOT an 
absolute path
     assertFalse(HopVfs.isAbsolutePath("C:test.hpl"));
+    // Tilde followed by non-separator is not a home path
+    assertFalse(HopVfs.isAbsolutePath("~test.hpl"));
   }
 
   @Test
@@ -143,4 +151,66 @@ class HopVfsTest {
               });
     }
   }
+
+  @Test
+  void testResolveHomeDirectory() {
+    String userHome = System.getProperty("user.home");
+    while (userHome.length() > 1 && (userHome.endsWith("/") || 
userHome.endsWith("\\"))) {
+      userHome = userHome.substring(0, userHome.length() - 1);
+    }
+
+    // Bare ~
+    assertEquals(userHome, HopVfs.resolveHomeDirectory("~"));
+
+    // POSIX path
+    assertEquals(userHome + "/project/file.txt", 
HopVfs.resolveHomeDirectory("~/project/file.txt"));
+
+    // Windows backslash path
+    assertEquals(
+        userHome + "\\project\\file.txt", 
HopVfs.resolveHomeDirectory("~\\project\\file.txt"));
+
+    // file:// and file: prefixes
+    String filePrefixExpected =
+        "file://" + (userHome.startsWith("/") ? "" : "/") + userHome + 
"/project/file.txt";
+    assertEquals(filePrefixExpected, 
HopVfs.resolveHomeDirectory("file://~/project/file.txt"));
+    assertEquals(
+        "file:" + userHome + "/project/file.txt",
+        HopVfs.resolveHomeDirectory("file:~/project/file.txt"));
+
+    // Tilde not at the start should NOT be replaced
+    assertEquals("/opt/hop/~", HopVfs.resolveHomeDirectory("/opt/hop/~"));
+    assertEquals("/opt/hop/~/test", 
HopVfs.resolveHomeDirectory("/opt/hop/~/test"));
+    assertEquals("foo~bar", HopVfs.resolveHomeDirectory("foo~bar"));
+    assertEquals("s3://bucket/~/key", 
HopVfs.resolveHomeDirectory("s3://bucket/~/key"));
+
+    // Tilde followed by non-separator characters should NOT be replaced
+    assertEquals("~otheruser/dir", 
HopVfs.resolveHomeDirectory("~otheruser/dir"));
+    assertEquals("~temp", HopVfs.resolveHomeDirectory("~temp"));
+
+    // Null and empty
+    assertEquals(null, HopVfs.resolveHomeDirectory(null));
+    assertEquals("", HopVfs.resolveHomeDirectory(""));
+
+    // Custom variable override
+    Variables vars = new Variables();
+    vars.setVariable("user.home", "/custom/home");
+    assertEquals("/custom/home", HopVfs.resolveHomeDirectory("~", vars));
+    assertEquals("/custom/home/sub/file.csv", 
HopVfs.resolveHomeDirectory("~/sub/file.csv", vars));
+    assertEquals(
+        "/custom/home\\sub\\file.csv", 
HopVfs.resolveHomeDirectory("~\\sub\\file.csv", vars));
+  }
+
+  @Test
+  void testGetFileObjectWithTilde() throws Exception {
+    String userHome = System.getProperty("user.home");
+    FileObject homeObj = HopVfs.getFileObject("~");
+    assertNotNull(homeObj);
+    assertEquals(HopVfs.getFileObject(userHome).getName().getURI(), 
homeObj.getName().getURI());
+
+    FileObject childObj = HopVfs.getFileObject("~/test-file-hop.txt");
+    assertNotNull(childObj);
+    assertEquals(
+        HopVfs.getFileObject(userHome + 
"/test-file-hop.txt").getName().getURI(),
+        childObj.getName().getURI());
+  }
 }
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/projects/projects-environments.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/projects/projects-environments.adoc
index 9f79aa2dc2..fb92a64da5 100644
--- 
a/docs/hop-user-manual/modules/ROOT/pages/projects/projects-environments.adoc
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/projects/projects-environments.adoc
@@ -47,7 +47,7 @@ image:hop-gui/environment/create-project-dialog.png[Project 
Properties Basic tab
 |===
 |Property|Description|Variables Supported|Mandatory|Default
 |Name|The project name|Yes|No|
-|Home folder|The folder where the project is located|Yes|No|
+|Home folder|The folder where the project is located. Paths starting with `~` 
(e.g. `~/my-project` or `~\my-project` on Windows) automatically resolve to the 
user's home directory (`{openvar}user.home{closevar}`).|Yes|No|
 |This project is read only|When checked, Hop does not write 
`project-config.json` (archives, HTTP, and similar)|No|No|unchecked
 |Configuration file (relative path)|The project's configuration json, relative 
to the home folder.|Yes|Yes|`project-config.json`
 |Description|A description for this project|No|No|
@@ -160,7 +160,7 @@ image:hop-gui/environment/environment-files.png[Environment 
configuration files,
 
 image::hop-gui/environment/environment-variables.png[Environment 
Variables,width="80%"]
 
-When you browse for a file or directory in Hop Gui with a project active, 
paths under a matching path-like variable are rewritten automatically. For 
example, `{openvar}PROJECT_HOME{closevar}` is used for files under the project 
home, and an environment variable such as `SOURCE_FILES=/data/incoming` 
rewrites a selection under that folder to `{openvar}SOURCE_FILES{closevar}/…`. 
If more than one variable matches, the longest (most specific) path wins. The 
same applies to path variables defi [...]
+When you browse for a file or directory in Hop Gui with a project active, 
paths under a matching path-like variable are rewritten automatically. For 
example, `{openvar}PROJECT_HOME{closevar}` is used for files under the project 
home, and an environment variable such as `SOURCE_FILES=/data/incoming` (or 
`SOURCE_FILES=~/incoming`) rewrites a selection under that folder to 
`{openvar}SOURCE_FILES{closevar}/…`. If more than one variable matches, the 
longest (most specific) path wins. The same [...]
 
 After creating an environment the user interface will switch to it.
 
diff --git a/docs/hop-user-manual/modules/ROOT/pages/variables.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/variables.adoc
index 9d97755913..1c6bbc99c9 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/variables.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/variables.adoc
@@ -56,6 +56,16 @@ ${ENVIRONMENT_HOME}/input/source-file.txt
 // CTRL-space snippet
 include::snippets/variables/control-space.adoc[]
 
+== User home directory shortcut (~)
+
+In file and folder paths, Hop automatically expands a leading tilde character 
(`~`) to the current user's home directory (`${user.home}`). This works across 
all operating systems (Linux, macOS, and Windows with both `\` and `/`):
+
+* `~` resolves to `${user.home}`
+* `~/projects/my-project` resolves to `${user.home}/projects/my-project`
+* `~\projects\my-project` resolves to `${user.home}\projects\my-project`
+
+NOTE: The tilde character is only expanded at the beginning of a path (e.g. 
`~`, `~/...`, `~\...`). A tilde anywhere else in a string (e.g. `folder/~` or 
`foo~bar`) or followed by non-separator characters (e.g. `~user`) is treated as 
a literal character.
+
 == Hexadecimal values
 
 In rare cases you might have a need to enter non-character values as 
separators in 'binary' text files with for example a zero byte as a separator.
diff --git a/docs/hop-user-manual/modules/ROOT/pages/vfs.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/vfs.adoc
index fc6e15454d..fd4179c813 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/vfs.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/vfs.adoc
@@ -88,6 +88,14 @@ a|URI Format
 Where `absolute-path` is a valid absolute file name for the local platform.
 UNC paths are accepted by Apache VFS, but are not reliably supported in Hop — 
see <<windows-unc-paths, Windows UNC paths>>.
 
+Hop supports using the tilde character (`~`) at the start of a local file path 
to represent the current user's home directory (`${user.home}`). This works 
across all operating systems, including Windows (with both `\` and `/`), macOS, 
and Linux.
+
+* Bare tilde: `~` resolves to `${user.home}` (e.g. `/home/someuser` or 
`C:\Users\someuser`).
+* With path separators: `~/somedir` or `~\somedir` resolves to 
`${user.home}/somedir` or `${user.home}\somedir`.
+* With `file:` scheme: `file://~/somedir` or `file:~/somedir`.
+
+NOTE: The tilde character is only expanded when it is at the **start** of a 
path. A tilde appearing anywhere else in a path (such as `/opt/hop/~` or 
`my~file.txt`) or followed by non-separator characters (such as `~otheruser` or 
`~temp`) is treated literally and is not expanded.
+
 Examples
 
 * `+file:///home/someuser/somedir+`
@@ -96,6 +104,9 @@ Examples
 * `+/home/someuser/somedir+`
 * `+c:\program files\some dir+`
 * `+c:/program files/some dir+`
+* `+~/somedir+`
+* `+~\somedir+`
+* `+file://~/somedir+`
 
 
 //
diff --git 
a/plugins/misc/git/src/main/java/org/apache/hop/git/model/CaseInsensitiveIgnores.java
 
b/plugins/misc/git/src/main/java/org/apache/hop/git/model/CaseInsensitiveIgnores.java
index 2613d70d0c..59c2a96dc8 100644
--- 
a/plugins/misc/git/src/main/java/org/apache/hop/git/model/CaseInsensitiveIgnores.java
+++ 
b/plugins/misc/git/src/main/java/org/apache/hop/git/model/CaseInsensitiveIgnores.java
@@ -26,6 +26,7 @@ import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.core.vfs.HopVfs;
 import org.eclipse.jgit.ignore.FastIgnoreRule;
 import org.eclipse.jgit.ignore.IgnoreNode;
 import org.eclipse.jgit.lib.ConfigConstants;
@@ -148,9 +149,6 @@ class CaseInsensitiveIgnores {
   }
 
   private static String replaceUserHome(String path) {
-    if (path.startsWith("~/")) {
-      return System.getProperty("user.home") + path.substring(1);
-    }
-    return path;
+    return HopVfs.resolveHomeDirectory(path);
   }
 }
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/PathVariableReplacer.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/PathVariableReplacer.java
index 52e7e3f13b..e25fb6513b 100644
--- 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/PathVariableReplacer.java
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/PathVariableReplacer.java
@@ -153,7 +153,10 @@ public final class PathVariableReplacer {
     if (value.contains("${") || value.contains(",")) {
       return false;
     }
-    // Absolute local path, Windows drive path, UNC, or VFS URI
+    // Tilde home path, absolute local path, Windows drive path, UNC, or VFS 
URI
+    if (value.equals("~") || value.startsWith("~/") || 
value.startsWith("~\\")) {
+      return true;
+    }
     if (value.startsWith("/") || value.startsWith("\\")) {
       return true;
     }
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/ProjectsMetadataExporter.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/ProjectsMetadataExporter.java
index 2b7df4a35e..acb4b557e4 100644
--- 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/ProjectsMetadataExporter.java
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/ProjectsMetadataExporter.java
@@ -163,11 +163,8 @@ public final class ProjectsMetadataExporter {
       resolved = Defaults.DEFAULT_AUTO_EXPORT_METADATA_FILENAME;
     }
 
-    // Absolute filesystem path or VFS scheme (e.g. file://, s3://)
-    if (resolved.startsWith("/")
-        || resolved.startsWith("\\")
-        || (resolved.length() > 2 && resolved.charAt(1) == ':')
-        || resolved.contains("://")) {
+    // Absolute filesystem path, tilde home path, or VFS scheme (e.g. file://, 
s3://)
+    if (HopVfs.isAbsolutePath(resolved)) {
       return resolved;
     }
 
diff --git 
a/plugins/misc/projects/src/test/java/org/apache/hop/projects/util/PathVariableReplacerTest.java
 
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/util/PathVariableReplacerTest.java
index f917843086..83a41b1f6a 100644
--- 
a/plugins/misc/projects/src/test/java/org/apache/hop/projects/util/PathVariableReplacerTest.java
+++ 
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/util/PathVariableReplacerTest.java
@@ -148,6 +148,9 @@ class PathVariableReplacerTest {
     assertTrue(PathVariableReplacer.isCandidatePathValue("/tmp/source"));
     
assertTrue(PathVariableReplacer.isCandidatePathValue("file:///tmp/source"));
     assertTrue(PathVariableReplacer.isCandidatePathValue("C:\\data\\files"));
+    assertTrue(PathVariableReplacer.isCandidatePathValue("~"));
+    assertTrue(PathVariableReplacer.isCandidatePathValue("~/projects/demo"));
+    assertTrue(PathVariableReplacer.isCandidatePathValue("~\\projects\\demo"));
   }
 
   @Test
@@ -168,4 +171,14 @@ class PathVariableReplacerTest {
         "${SOURCE_FILES}/input/data.csv",
         PathVariableReplacer.replacePathWithVariable(variables, selected));
   }
+
+  @Test
+  void replacesTildePathRootWithVariable() {
+    Path userHome = Path.of(System.getProperty("user.home"));
+    Path userProj = userHome.resolve("test-proj-hop-replacer");
+    variables.setVariable("USER_PROJ", "~/test-proj-hop-replacer");
+    String selected = userProj.resolve("data.csv").toString();
+    assertEquals(
+        "${USER_PROJ}/data.csv", 
PathVariableReplacer.replacePathWithVariable(variables, selected));
+  }
 }

Reply via email to