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));
+ }
}