Repository: mesonbuild/meson

Issue:
[Java/JAR] Cannot bundle resources files
**Describe the bug**
One cannot bundle resources files (images, audio, properties or other arbitrary files) in a JAR via Meson’s `jar()` function

**To Reproduce**
```meson
project('java-project', 'java', version : '0.1')
jar('project', 'com.example.Main.java', 'com.example.icon.png')
```

**Expected behavior**
Any resource file would be included in the JAR file as-is

**system parameters**
* Native build
* CentOS 7
* Python 3.6.8
* Meson 0.55.1
* Ninja 1.10.2

Ping @tristan957 as asked :-)


Discussion:
Can you leave a link to website which documents the jar command and adding resources?
Here is [the jar manual](https://docs.oracle.com/javase/7/docs/technotes/tools/windows/jar.html) though basically it’s a fancy `zip` wrapper and it actually accepts any file, AFAICT
@tristan957  there is a resources keyword argument used by c#, not sure if the idea of resources is equivalent though
I'm not familiar at all with C#, but it sounds like it could be. Where is the documentation for it?
Oh lovely, there doesn't appear to be any. We don't even document the existence of the keyword argument, I found out about it doing the typed_*_args conversion of the build_targets.

Here's the c# documentation: https://docs.microsoft.com/en-us/dotnet/core/extensions/create-resource-files
@dcbaker I want to pick your brain a bit. Maven uses the well-known `src/main/resources` folder as the root for all resources which are to be included in the JAR. Right now I am working on adding `java_resources` to `jar()` where `java_resources` is a `T.List[mesonlib.FileOrString]`.

My current tree structure in the test I am adding:

```
.
├── meson.build
└── src
    ├── com
    │   └── mesonbuild
    │       └── Resources.java
    ├── meson.build
    └── resources
        ├── resource1.txt
        └── subdir
            └── resource2.txt
```

`src/meson.build`
```meson
sources = files('com/mesonbuild/Resources.java')

resources = jar(
  meson.project_name(),
  sources,
  main_class: 'com.mesonbuild.Resources',
  java_resources: files(
    'resources/resource1.txt',
    'resources/subdir/resource2.txt'
  )
)
```

Contents of zip

```
$ unzip -l build/src/resources.jar
Archive:  build/src/resources.jar
  Length      Date    Time    Name
---------  ---------- -----   ----
        0  02-25-2022 01:02   META-INF/
      102  02-25-2022 01:02   META-INF/MANIFEST.MF
        0  02-25-2022 01:02   com/
        0  02-25-2022 01:02   com/mesonbuild/
      552  02-25-2022 01:02   com/mesonbuild/Resources.class
       28  02-25-2022 01:02   src/resources/resource1.txt
       33  02-25-2022 00:53   src/resources/subdir/resource2.txt
---------                     -------
      715                     7 files
```

Ideally this becomes:

```
$ unzip -l build/src/resources.jar
Archive:  build/src/resources.jar
  Length      Date    Time    Name
---------  ---------- -----   ----
        0  02-25-2022 01:02   META-INF/
      102  02-25-2022 01:02   META-INF/MANIFEST.MF
        0  02-25-2022 01:02   com/
        0  02-25-2022 01:02   com/mesonbuild/
      552  02-25-2022 01:02   com/mesonbuild/Resources.class
       28  02-25-2022 01:02   resource1.txt ***************
       33  02-25-2022 00:53   subdir/resource2.txt ******************
---------                     -------
      715                     7 files
```

What are your thoughts here on the best way to solve this? Seems like it would be useful to just take a directory similar to Maven, but then you can't enumerate the files that would trigger a rebuild of the JAR. Almost thinking this could benefit from your `structured_sources()` PR where Meson copies the resources into the build tree. Not sure. Let me know what you think :).
Side note. There seems to be a bug in meson if you move the contents of that meson.build file into the root meson.build file where the class file gets generated in `$PRIVATE_DIR/com/mesonbuild/Resources.class`, but meson writes `$PRIVATE_DIR/src/com/mesonbuild/Resources.class` to the build.ninja file. This leads an always dirty build.

```ninja
build resources.jar.p/src/com/mesonbuild/Resources.class: java_COMPILER ../src/com/mesonbuild/Resources.java
 ARGS = -g -d resources.jar.p -s resources.jar.p -sourcepath '/home/tristan957/Projects/meson/test$ cases/java/10$ resources:/home/tristan957/Projects/meson/test$ cases/java/10$ resources/build:/home/tristan957/Projects/meson/test$ cases/java/10$ resources/build'
```

```
$ ninja -C build/ -d explain
ninja: Entering directory `build/'
ninja explain: output resources.jar.p/src/com/mesonbuild/Resources.class doesn't exist
ninja explain: resources.jar.p/src/com/mesonbuild/Resources.class is dirty
ninja explain: resources.jar is dirty
[2/2] Creating JAR resources.jar
```
```
$ tree build/resources.jar.p/
build/resources.jar.p/
├── com
│   └── mesonbuild
│       └── Resources.class
├── META-INF
│   └── MANIFEST.MF
└── src
    └── com
        └── mesonbuild

6 directories, 2 files
```

Resolving patch:
diff --git a/mesonbuild/backend/ninjabackend.py b/mesonbuild/backend/ninjabackend.py
--- a/mesonbuild/backend/ninjabackend.py
+++ b/mesonbuild/backend/ninjabackend.py
@@ -1231,10 +1231,11 @@ def generate_phony(self):
         elem = NinjaBuildElement(self.all_outputs, 'PHONY', 'phony', '')
         self.add_build(elem)
 
-    def generate_jar_target(self, target):
+    def generate_jar_target(self, target: build.Jar):
         fname = target.get_filename()
         outname_rel = os.path.join(self.get_target_dir(target), fname)
         src_list = target.get_sources()
+        resources = target.get_java_resources()
         class_list = []
         compiler = target.compilers['java']
         c = 'c'
@@ -1280,6 +1281,9 @@ def generate_jar_target(self, target):
         commands += ['-C', self.get_target_private_dir(target), '.']
         elem = NinjaBuildElement(self.all_outputs, outname_rel, jar_rule, [])
         elem.add_dep(class_dep_list)
+        if resources:
+            # Copy all resources into the root of the jar.
+            elem.add_orderdep(self.__generate_sources_structure(Path(self.get_target_private_dir(target)), resources)[0])
         elem.add_item('ARGS', commands)
         self.add_build(elem)
         # Create introspection information
@@ -1663,11 +1667,10 @@ def _generate_copy_target(self, src: 'mesonlib.FileOrString', output: Path) -> N
         elem.add_orderdep(instr)
         self.add_build(elem)
 
-    def __generate_compile_structure(self, target: build.BuildTarget) -> T.Tuple[T.List[str], T.Optional[str]]:
+    def __generate_sources_structure(self, root: Path, structured_sources: build.StructuredSources) -> T.Tuple[T.List[str], T.Optional[str]]:
         first_file: T.Optional[str] = None
         orderdeps: T.List[str] = []
-        root = Path(self.get_target_private_dir(target)) / 'structured'
-        for path, files in target.structured_sources.sources.items():
+        for path, files in structured_sources.sources.items():
             for file in files:
                 if isinstance(file, File):
                     out = root / path / Path(file.fname).name
@@ -1706,7 +1709,8 @@ def generate_rust_target(self, target: build.BuildTarget) -> None:
         main_rust_file = None
         if target.structured_sources:
             if target.structured_sources.needs_copy():
-                _ods, main_rust_file = self.__generate_compile_structure(target)
+                _ods, main_rust_file = self.__generate_sources_structure(Path(
+                    self.get_target_private_dir(target)) / 'structured', target.structured_sources)
                 orderdeps.extend(_ods)
             else:
                 # The only way to get here is to have only files in the "root"
diff --git a/mesonbuild/build.py b/mesonbuild/build.py
--- a/mesonbuild/build.py
+++ b/mesonbuild/build.py
@@ -36,7 +36,7 @@
     extract_as_list, typeslistify, stringlistify, classify_unity_sources,
     get_filenames_templates_dict, substitute_values, has_path_sep,
     OptionKey, PerMachineDefaultable,
-    MesonBugException,
+    MesonBugException
 )
 from .compilers import (
     Compiler, is_object, clink_langs, sort_clink, lang_suffixes,
@@ -122,7 +122,7 @@
 known_shlib_kwargs = known_build_target_kwargs | {'version', 'soversion', 'vs_module_defs', 'darwin_versions'}
 known_shmod_kwargs = known_build_target_kwargs | {'vs_module_defs'}
 known_stlib_kwargs = known_build_target_kwargs | {'pic', 'prelink'}
-known_jar_kwargs = known_exe_kwargs | {'main_class'}
+known_jar_kwargs = known_exe_kwargs | {'main_class', 'java_resources'}
 
 @lru_cache(maxsize=None)
 def get_target_macos_dylib_install_name(ld) -> str:
@@ -2712,6 +2712,7 @@ def __init__(self, name: str, subdir: str, subproject: str, for_machine: Machine
         self.filename = self.name + '.jar'
         self.outputs = [self.filename]
         self.java_args = kwargs.get('java_args', [])
+        self.java_resources: T.Optional[StructuredSources] = kwargs.get('java_resources', None)
 
     def get_main_class(self):
         return self.main_class
@@ -2722,6 +2723,9 @@ def type_suffix(self):
     def get_java_args(self):
         return self.java_args
 
+    def get_java_resources(self) -> T.Optional[StructuredSources]:
+        return self.java_resources
+
     def validate_install(self, environment):
         # All jar targets are installable.
         pass


Test patch:
diff --git a/test cases/java/10 resources/meson.build b/test cases/java/10 resources/meson.build
new file mode 100644
--- /dev/null
+++ b/test cases/java/10 resources/meson.build	
@@ -0,0 +1,7 @@
+project('resources', ['java'])
+
+if meson.backend() != 'ninja'
+  error('MESON_SKIP_TEST: only valid on backends which support jar()')
+endif
+
+subdir('src')
diff --git a/test cases/java/10 resources/src/com/mesonbuild/Resources.java b/test cases/java/10 resources/src/com/mesonbuild/Resources.java
new file mode 100644
--- /dev/null
+++ b/test cases/java/10 resources/src/com/mesonbuild/Resources.java	
@@ -0,0 +1,26 @@
+package com.mesonbuild;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+
+public class Resources {
+    public static void main(String[] args) throws IOException {
+        try (InputStreamReader reader = new InputStreamReader(
+                Resources.class.getResourceAsStream("/resource1.txt"),
+                StandardCharsets.UTF_8)) {
+            final BufferedReader buffered = new BufferedReader(reader);
+
+            assert buffered.readLine() == "1";
+        }
+
+        try (InputStreamReader reader = new InputStreamReader(
+                Resources.class.getResourceAsStream("/subdir/resource2.txt"),
+                StandardCharsets.UTF_8)) {
+            final BufferedReader buffered = new BufferedReader(reader);
+
+            assert buffered.readLine() == "2";
+        }
+    }
+}
diff --git a/test cases/java/10 resources/src/meson.build b/test cases/java/10 resources/src/meson.build
new file mode 100644
--- /dev/null
+++ b/test cases/java/10 resources/src/meson.build	
@@ -0,0 +1,13 @@
+sources = files('com/mesonbuild/Resources.java')
+
+resources = jar(
+  meson.project_name(),
+  sources,
+  main_class: 'com.mesonbuild.Resources',
+  java_resources: structured_sources(
+    files('resources/resource1.txt'),
+    {
+      'subdir': files('resources/subdir/resource2.txt'),
+    }
+  )
+)
diff --git a/test cases/java/10 resources/src/resources/resource1.txt b/test cases/java/10 resources/src/resources/resource1.txt
new file mode 100644
--- /dev/null
+++ b/test cases/java/10 resources/src/resources/resource1.txt	
@@ -0,0 +1 @@
+1
diff --git a/test cases/java/10 resources/src/resources/subdir/resource2.txt b/test cases/java/10 resources/src/resources/subdir/resource2.txt
new file mode 100644
--- /dev/null
+++ b/test cases/java/10 resources/src/resources/subdir/resource2.txt	
@@ -0,0 +1 @@
+2