Add support for VIRTUAL_FILE

`VIRTUAL_FILE` declares a file in a new virtual file system.
These files can now be referenced by the `SHADER` commands instead of using inline source.

The DXC compiler will first look in the virtual file system for any `#include`s before falling back to the standard file system.

This allows us to write tests that exercise the compiler and debugger handling of multiple files.

Added `relative_includes_hlsl.amber` which checks that DXC correctly includes relative to the current file, not the root file.
diff --git a/Android.mk b/Android.mk
index b373245..ae83c26 100644
--- a/Android.mk
+++ b/Android.mk
@@ -47,6 +47,7 @@
     src/type_parser.cc \
     src/value.cc \
     src/verifier.cc \
+    src/virtual_file_store.cc \
     src/vkscript/command_parser.cc \
     src/vkscript/datum_type_parser.cc \
     src/vkscript/parser.cc \
diff --git a/docs/amber_script.md b/docs/amber_script.md
index 56430f7..f1540f9 100644
--- a/docs/amber_script.md
+++ b/docs/amber_script.md
@@ -68,8 +68,63 @@
 SET ENGINE_DATA {engine data variable} {value}*
 ```
 
+### Virtual File Store
+
+Each amber script contains a virtual file system that can store files of textual
+data. This lets you bundle multiple source files into a single, hermetic amber
+script file.
+
+Virtual files are declared using the `VIRTUAL_FILE` command:
+
+```groovy
+VIRTUAL_FILE {path}
+ {file-content}
+END
+```
+
+Paths must be unique.
+
+Shaders can directly reference these virtual files for their source. \
+HLSL shaders that `#include` other `.hlsl` files will first check the virtual
+file system, before falling back to the standard file system.
+
 ### Shaders
 
+Shader programs are declared using the `SHADER` command. \
+Shaders can be declared as `PASSTHROUGH`, with inlined source or using source
+from a `VIRTUAL_FILE`.
+
+Pass-through shader:
+
+```groovy
+# Creates a passthrough vertex shader. The shader passes the vec4 at input
+# location 0 through to the `gl_Position`.
+SHADER vertex {shader_name} PASSTHROUGH
+```
+
+Shader using inlined source:
+
+```groovy
+# Creates a shader of |shader_type| with the given |shader_name|. The shader
+# will be of |shader_format|. The shader source then follows and is terminated
+# with the |END| tag.
+SHADER {shader_type} {shader_name} {shader_format}
+{shader_source}
+END
+```
+
+Shader using source from `VIRTUAL_FILE`:
+
+```groovy
+# Creates a shader of |shader_type| with the given |shader_name|. The shader
+# will be of |shader_format|. The shader will use the virtual file with |path|.
+SHADER {shader_type} {shader_name} {shader_format} VIRTUAL_FILE {path}
+```
+
+`{shader_name}` is used to identify the shader to attach to `PIPELINE`s,
+
+`{shader_type}` and `{shader_format}` are described below:
+
 #### Shader Type
  * `vertex`
  * `fragment`
@@ -92,24 +147,11 @@
 
 #### Shader Format
  * `GLSL`  (with glslang)
- * `HLSL`  (with dxc or glslang if dxc disabled)  -- future
+ * `HLSL`  (with dxc or glslang if dxc disabled)
  * `SPIRV-ASM` (with spirv-as)
  * `SPIRV-HEX` (decoded straight to SPIR-V)
  * `OPENCL-C` (with clspv)
 
-```groovy
-# Creates a passthrough vertex shader. The shader passes the vec4 at input
-# location 0 through to the `gl_Position`.
-SHADER vertex {shader_name} PASSTHROUGH
-
-# Creates a shader of |shader_type| with the given |shader_name|. The shader
-# will be of |shader_format|. The shader should then be inlined before the
-# |END| tag.
-SHADER {shader_type} {shader_name} {shader_format}
-...
-END
-```
-
 ### Buffers
 
 An AmberScript buffer represents a set of contiguous bits. This can be used for
@@ -471,7 +513,7 @@
 
 ```groovy
 # Run the given |pipeline_name| which must be a `graphics` pipeline. The
-# grid at |x|, |y|, |width|x|height|, |columns|x|rows| will be rendered. 
+# grid at |x|, |y|, |width|x|height|, |columns|x|rows| will be rendered.
 # Ignores VERTEX_DATA and INDEX_DATA on the given pipeline.
 # For columns, rows of (5, 4) a total of 5*4=20 rectangles will be drawn.
 RUN {pipeline_name} \
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index a50c33c..c18f390 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -39,6 +39,7 @@
     type_parser.cc
     value.cc
     verifier.cc
+    virtual_file_store.cc
     vkscript/command_parser.cc
     vkscript/datum_type_parser.cc
     vkscript/parser.cc
@@ -163,6 +164,7 @@
     type_parser_test.cc
     type_test.cc
     verifier_test.cc
+    virtual_file_store_test.cc
     vkscript/command_parser_test.cc
     vkscript/datum_type_parser_test.cc
     vkscript/parser_test.cc
diff --git a/src/amberscript/parser.cc b/src/amberscript/parser.cc
index 60292b0..10162b8 100644
--- a/src/amberscript/parser.cc
+++ b/src/amberscript/parser.cc
@@ -200,6 +200,8 @@
       r = ParseStruct();
     } else if (tok == "SAMPLER") {
       r = ParseSampler();
+    } else if (tok == "VIRTUAL_FILE") {
+      r = ParseVirtualFile();
     } else {
       r = Result("unknown token: " + tok);
     }
@@ -370,19 +372,42 @@
 
   shader->SetFormat(format);
 
-  r = ValidateEndOfStatement("SHADER command");
-  if (!r.IsSuccess())
-    return r;
+  token = tokenizer_->PeekNextToken();
+  if (token->IsIdentifier() && token->AsString() == "VIRTUAL_FILE") {
+    tokenizer_->NextToken();  // Skip VIRTUAL_FILE
 
-  std::string data = tokenizer_->ExtractToNext("END");
-  if (data.empty())
-    return Result("SHADER must not be empty");
+    token = tokenizer_->NextToken();
+    if (!token->IsIdentifier() && !token->IsString())
+      return Result("expected virtual file path after VIRTUAL_FILE");
 
-  shader->SetData(data);
+    r = ValidateEndOfStatement("SHADER command");
+    if (!r.IsSuccess())
+      return r;
 
-  token = tokenizer_->NextToken();
-  if (!token->IsIdentifier() || token->AsString() != "END")
-    return Result("SHADER missing END command");
+    auto path = token->AsString();
+
+    std::string data;
+    r = script_->GetVirtualFile(path, &data);
+    if (!r.IsSuccess()) {
+      return r;
+    }
+
+    shader->SetData(data);
+  } else {
+    r = ValidateEndOfStatement("SHADER command");
+    if (!r.IsSuccess())
+      return r;
+
+    std::string data = tokenizer_->ExtractToNext("END");
+    if (data.empty())
+      return Result("SHADER must not be empty");
+
+    shader->SetData(data);
+
+    token = tokenizer_->NextToken();
+    if (!token->IsIdentifier() || token->AsString() != "END")
+      return Result("SHADER missing END command");
+  }
 
   r = script_->AddShader(std::move(shader));
   if (!r.IsSuccess())
@@ -2896,5 +2921,25 @@
   return {};
 }
 
+Result Parser::ParseVirtualFile() {
+  auto token = tokenizer_->NextToken();
+  if (!token->IsIdentifier() && !token->IsString())
+    return Result("invalid virtual file path");
+
+  auto path = token->AsString();
+
+  auto r = ValidateEndOfStatement("VIRTUAL_FILE command");
+  if (!r.IsSuccess())
+    return r;
+
+  auto data = tokenizer_->ExtractToNext("END");
+
+  token = tokenizer_->NextToken();
+  if (!token->IsIdentifier() || token->AsString() != "END")
+    return Result("VIRTUAL_FILE missing END command");
+
+  return script_->AddVirtualFile(path, data);
+}
+
 }  // namespace amberscript
 }  // namespace amber
diff --git a/src/amberscript/parser.h b/src/amberscript/parser.h
index 8f62e90..524cb43 100644
--- a/src/amberscript/parser.h
+++ b/src/amberscript/parser.h
@@ -97,6 +97,8 @@
                      Format* fmt,
                      std::vector<Value>* values);
 
+  Result ParseVirtualFile();
+
   std::unique_ptr<Tokenizer> tokenizer_;
   std::vector<std::unique_ptr<Command>> command_list_;
 };
diff --git a/src/amberscript/parser_shader_test.cc b/src/amberscript/parser_shader_test.cc
index 4c2ca69..07024a2 100644
--- a/src/amberscript/parser_shader_test.cc
+++ b/src/amberscript/parser_shader_test.cc
@@ -235,6 +235,54 @@
   EXPECT_EQ("2: extra parameters after SHADER command: INVALID", r.Error());
 }
 
+TEST_F(AmberScriptParserTest, ShaderVirtualFile) {
+  std::string in = R"(#!amber
+VIRTUAL_FILE my_shader.hlsl
+My shader source
+END
+
+SHADER vertex my_shader HLSL VIRTUAL_FILE my_shader.hlsl
+)";
+
+  Parser parser;
+  Result r = parser.Parse(in);
+  ASSERT_EQ(r.Error(), "");
+
+  auto script = parser.GetScript();
+  auto shader = script->GetShader("my_shader");
+  ASSERT_TRUE(shader != nullptr);
+  auto source = shader->GetData();
+  ASSERT_EQ("My shader source\n", shader->GetData());
+}
+
+TEST_F(AmberScriptParserTest, VirtualFileDuplicatePath) {
+  std::string in = R"(#!amber
+VIRTUAL_FILE my.file
+Blah
+END
+
+VIRTUAL_FILE my.file
+Blah
+END
+)";
+
+  Parser parser;
+  Result r = parser.Parse(in);
+  ASSERT_EQ(r.Error(), "8: Virtual file 'my.file' already declared");
+}
+
+TEST_F(AmberScriptParserTest, VirtualFileEmptyPath) {
+  std::string in = R"(#!amber
+VIRTUAL_FILE ""
+Blah
+END
+)";
+
+  Parser parser;
+  Result r = parser.Parse(in);
+  ASSERT_EQ(r.Error(), "4: Virtual file path was empty");
+}
+
 struct ShaderTypeData {
   const char* name;
   ShaderType type;
@@ -315,6 +363,7 @@
   EXPECT_EQ(test_data.format, shader->GetFormat());
   EXPECT_EQ(shader_result, shader->GetData());
 }
+
 INSTANTIATE_TEST_SUITE_P(
     AmberScriptParserTestsShaderFormat,
     AmberScriptParserShaderFormatTest,
diff --git a/src/dxc_helper.cc b/src/dxc_helper.cc
index b47616a..8ab2ebd 100644
--- a/src/dxc_helper.cc
+++ b/src/dxc_helper.cc
@@ -18,6 +18,7 @@
 #include <sstream>
 
 #include "src/platform.h"
+#include "src/virtual_file_store.h"
 
 #if AMBER_PLATFORM_WINDOWS
 #pragma warning(push)
@@ -25,8 +26,6 @@
 #pragma warning(disable : 4003)
 #endif  // AMBER_PLATFORM_WINDOWS
 
-// clang-format off
-// The order here matters, so don't reformat.
 #pragma clang diagnostic push
 #pragma clang diagnostic ignored "-Wreserved-id-macro"
 #pragma clang diagnostic ignored "-Wextra-semi"
@@ -40,22 +39,25 @@
 #pragma clang diagnostic ignored "-Wdocumentation-unknown-command"
 #pragma clang diagnostic ignored "-Wundef"
 #pragma clang diagnostic ignored "-Wunused-function"
+#pragma clang diagnostic ignored "-Wunused-parameter"
+#pragma clang diagnostic ignored "-Wzero-as-null-pointer-constant"
+#pragma GCC diagnostic push
+#pragma GCC diagnostic ignored "-Wunused-parameter"
 #ifndef __STDC_LIMIT_MACROS
 #define __STDC_LIMIT_MACROS
 #endif  // __STDC_LIMIT_MACROS
 #ifndef __STDC_CONSTANT_MACROS
 #define __STDC_CONSTANT_MACROS
 #endif  // __STDC_CONSTANT_MACROS
+
+// clang-format off
+// The order here matters, so don't reformat.
 #include "dxc/Support/Global.h"
 #include "dxc/Support/HLSLOptions.h"
 #include "dxc/dxcapi.h"
-#pragma clang diagnostic pop
+#include "dxc/Support/microcom.h"
 // clang-format on
 
-#if AMBER_PLATFORM_WINDOWS
-#pragma warning(pop)
-#endif  // AMBER_PLATFORM_WINDOWS
-
 namespace amber {
 namespace dxchelper {
 namespace {
@@ -79,12 +81,60 @@
   memcpy(binaryWords->data(), binaryStr.data(), binaryStr.size());
 }
 
+class IncludeHandler : public IDxcIncludeHandler {
+ public:
+  IncludeHandler(const VirtualFileStore* file_store,
+                 IDxcLibrary* dxc_lib,
+                 IDxcIncludeHandler* fallback)
+      : file_store_(file_store), dxc_lib_(dxc_lib), fallback_(fallback) {}
+
+  HRESULT STDMETHODCALLTYPE LoadSource(LPCWSTR pFilename,
+                                       IDxcBlob** ppIncludeSource) override {
+    std::wstring wide_path(pFilename);
+    std::string path = std::string(wide_path.begin(), wide_path.end());
+
+    std::string content;
+    Result r = file_store_->Get(path, &content);
+    if (r.IsSuccess()) {
+      IDxcBlobEncoding* source;
+      auto res = dxc_lib_->CreateBlobWithEncodingOnHeapCopy(
+          content.data(), static_cast<uint32_t>(content.size()), CP_UTF8,
+          &source);
+      if (res != S_OK) {
+        DxcCleanupThreadMalloc();
+        return res;
+      }
+      *ppIncludeSource = source;
+      return S_OK;
+    }
+
+    return fallback_->LoadSource(pFilename, ppIncludeSource);
+  }
+
+  HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid,
+                                           void** ppvObject) override {
+    return DoBasicQueryInterface<IDxcIncludeHandler>(this, iid, ppvObject);
+  }
+
+ private:
+  const VirtualFileStore* const file_store_;
+  IDxcLibrary* const dxc_lib_;
+  IDxcIncludeHandler* const fallback_;
+};
+
+#pragma GCC diagnostic pop
+#pragma clang diagnostic pop
+#if AMBER_PLATFORM_WINDOWS
+#pragma warning(pop)
+#endif  // AMBER_PLATFORM_WINDOWS
+
 }  // namespace
 
 Result Compile(const std::string& src,
                const std::string& entry,
                const std::string& profile,
                const std::string& spv_env,
+               const VirtualFileStore* virtual_files,
                std::vector<uint32_t>* generated_binary) {
   if (hlsl::options::initHlslOptTable()) {
     DxcCleanupThreadMalloc();
@@ -106,12 +156,15 @@
     return Result("DXC compile failure: CreateBlobFromFile");
   }
 
-  IDxcIncludeHandler* include_handler;
-  if (dxc_lib->CreateIncludeHandler(&include_handler) < 0) {
+  IDxcIncludeHandler* fallback_include_handler;
+  if (dxc_lib->CreateIncludeHandler(&fallback_include_handler) < 0) {
     DxcCleanupThreadMalloc();
     return Result("DXC compile failure: CreateIncludeHandler");
   }
 
+  IDxcIncludeHandler* include_handler =
+      new IncludeHandler(virtual_files, dxc_lib, fallback_include_handler);
+
   IDxcCompiler* compiler;
   if (DxcCreateInstance(CLSID_DxcCompiler, __uuidof(IDxcCompiler),
                         reinterpret_cast<void**>(&compiler)) < 0) {
diff --git a/src/dxc_helper.h b/src/dxc_helper.h
index 4edb55d..22082ce 100644
--- a/src/dxc_helper.h
+++ b/src/dxc_helper.h
@@ -21,6 +21,9 @@
 #include "amber/result.h"
 
 namespace amber {
+
+class VirtualFileStore;
+
 namespace dxchelper {
 
 // Passes the HLSL source code to the DXC compiler with SPIR-V CodeGen.
@@ -29,6 +32,7 @@
                const std::string& entry_str,
                const std::string& profile_str,
                const std::string& spv_env,
+               const VirtualFileStore* virtual_files,
                std::vector<uint32_t>* generated_binary);
 
 }  // namespace dxchelper
diff --git a/src/executor.cc b/src/executor.cc
index 5ff76d2..b0fb585 100644
--- a/src/executor.cc
+++ b/src/executor.cc
@@ -35,7 +35,8 @@
   for (auto& pipeline : script->GetPipelines()) {
     for (auto& shader_info : pipeline->GetShaders()) {
       ShaderCompiler sc(script->GetSpvTargetEnv(),
-                        options->disable_spirv_validation);
+                        options->disable_spirv_validation,
+                        script->GetVirtualFiles());
 
       Result r;
       std::vector<uint32_t> data;
diff --git a/src/script.cc b/src/script.cc
index d262821..b6dd743 100644
--- a/src/script.cc
+++ b/src/script.cc
@@ -14,11 +14,12 @@
 
 #include "src/script.h"
 
+#include "src/make_unique.h"
 #include "src/type_parser.h"
 
 namespace amber {
 
-Script::Script() = default;
+Script::Script() : virtual_files_(MakeUnique<VirtualFileStore>()) {}
 
 Script::~Script() = default;
 
diff --git a/src/script.h b/src/script.h
index b8f881b..e6bd2ee 100644
--- a/src/script.h
+++ b/src/script.h
@@ -31,6 +31,7 @@
 #include "src/pipeline.h"
 #include "src/sampler.h"
 #include "src/shader.h"
+#include "src/virtual_file_store.h"
 
 namespace amber {
 
@@ -222,6 +223,22 @@
     return it == name_to_type_.end() ? nullptr : it->second.get();
   }
 
+  // Returns the virtual file store.
+  VirtualFileStore* GetVirtualFiles() const { return virtual_files_.get(); }
+
+  /// Adds the virtual file with content |content| to the virtual file path
+  /// |path|. If there's already a virtual file with the given path, an error is
+  /// returned.
+  Result AddVirtualFile(const std::string& path, const std::string& content) {
+    return virtual_files_->Add(path, content);
+  }
+
+  /// Look up the virtual file by path. If the file was found, the content is
+  /// assigned to content.
+  Result GetVirtualFile(const std::string& path, std::string* content) const {
+    return virtual_files_->Get(path, content);
+  }
+
   type::Type* ParseType(const std::string& str);
 
  private:
@@ -245,6 +262,7 @@
   std::vector<std::unique_ptr<Pipeline>> pipelines_;
   std::vector<std::unique_ptr<type::Type>> types_;
   std::vector<std::unique_ptr<Format>> formats_;
+  std::unique_ptr<VirtualFileStore> virtual_files_;
 };
 
 }  // namespace amber
diff --git a/src/shader_compiler.cc b/src/shader_compiler.cc
index 5c6944b..4eb328b 100644
--- a/src/shader_compiler.cc
+++ b/src/shader_compiler.cc
@@ -48,8 +48,15 @@
 ShaderCompiler::ShaderCompiler() = default;
 
 ShaderCompiler::ShaderCompiler(const std::string& env,
-                               bool disable_spirv_validation)
-    : spv_env_(env), disable_spirv_validation_(disable_spirv_validation) {}
+                               bool disable_spirv_validation,
+                               VirtualFileStore* virtual_files)
+    : spv_env_(env),
+      disable_spirv_validation_(disable_spirv_validation),
+      virtual_files_(virtual_files) {
+  // Do not warn about virtual_files_ not being used.
+  // This is conditionally used based on preprocessor defines.
+  (void)virtual_files_;
+}
 
 ShaderCompiler::~ShaderCompiler() = default;
 
@@ -269,7 +276,7 @@
     return Result("Unknown shader type");
 
   return dxchelper::Compile(shader->GetData(), "main", target, spv_env_,
-                            result);
+                            virtual_files_, result);
 }
 #else
 Result ShaderCompiler::CompileHlsl(const Shader*,
diff --git a/src/shader_compiler.h b/src/shader_compiler.h
index fdf7430..e4caa62 100644
--- a/src/shader_compiler.h
+++ b/src/shader_compiler.h
@@ -23,6 +23,7 @@
 #include "amber/result.h"
 #include "src/pipeline.h"
 #include "src/shader.h"
+#include "src/virtual_file_store.h"
 
 namespace amber {
 
@@ -30,7 +31,9 @@
 class ShaderCompiler {
  public:
   ShaderCompiler();
-  ShaderCompiler(const std::string& env, bool disable_spirv_validation);
+  ShaderCompiler(const std::string& env,
+                 bool disable_spirv_validation,
+                 VirtualFileStore* virtual_files);
   ~ShaderCompiler();
 
   /// Returns a result code and a compilation of the given shader.
@@ -61,6 +64,7 @@
 
   std::string spv_env_;
   bool disable_spirv_validation_ = false;
+  VirtualFileStore* virtual_files_ = nullptr;
 };
 
 // Parses the SPIR-V environment string, and returns the corresponding
diff --git a/src/virtual_file_store.cc b/src/virtual_file_store.cc
new file mode 100644
index 0000000..a14d64c
--- /dev/null
+++ b/src/virtual_file_store.cc
@@ -0,0 +1,48 @@
+// Copyright 2020 The Amber Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/virtual_file_store.h"
+
+namespace amber {
+namespace {
+
+bool HasPrefix(const std::string& str, const std::string& prefix) {
+  return str.compare(0, prefix.size(), prefix) == 0;
+}
+
+std::string TrimPrefix(const std::string& str, const std::string& prefix) {
+  return HasPrefix(str, prefix) ? str.substr(prefix.length()) : str;
+}
+
+std::string ReplaceAll(std::string str,
+                       const std::string& substr,
+                       const std::string& replacement) {
+  size_t pos = 0;
+  while ((pos = str.find(substr, pos)) != std::string::npos) {
+    str.replace(pos, substr.length(), replacement);
+    pos += replacement.length();
+  }
+  return str;
+}
+
+}  // namespace
+
+std::string VirtualFileStore::GetCanonical(const std::string& path) {
+  auto canonical = path;
+  canonical = ReplaceAll(canonical, "\\", "/");
+  canonical = TrimPrefix(canonical, "./");
+  return canonical;
+}
+
+}  // namespace amber
diff --git a/src/virtual_file_store.h b/src/virtual_file_store.h
new file mode 100644
index 0000000..f3e3d65
--- /dev/null
+++ b/src/virtual_file_store.h
@@ -0,0 +1,75 @@
+// Copyright 2020 The Amber Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_VIRTUAL_FILE_STORE_H_
+#define SRC_VIRTUAL_FILE_STORE_H_
+
+#include <cassert>
+#include <string>
+#include <unordered_map>
+
+#include "amber/result.h"
+
+namespace amber {
+
+/// Stores a number of virtual files by path.
+class VirtualFileStore {
+ public:
+  /// Return the path sanitized into a canonical form.
+  static std::string GetCanonical(const std::string& path);
+
+  /// Adds the virtual file with content |content| to the virtual file path
+  /// |path|. If there's already a virtual file with the given path, an error is
+  /// returned.
+  Result Add(const std::string& path, const std::string& content) {
+    if (path.length() == 0) {
+      return Result("Virtual file path was empty");
+    }
+
+    auto canonical = GetCanonical(path);
+
+    auto it = files_by_path_.find(canonical);
+    if (it != files_by_path_.end()) {
+      return Result("Virtual file '" + path + "' already declared");
+    }
+    files_by_path_.emplace(canonical, content);
+    return {};
+  }
+
+  /// Look up the virtual file by path. If the file was found, the content is
+  /// assigned to content.
+  Result Get(const std::string& path, std::string* content) const {
+    assert(content);
+
+    if (path.length() == 0) {
+      return Result("Virtual file path was empty");
+    }
+
+    auto canonical = GetCanonical(path);
+
+    auto it = files_by_path_.find(canonical);
+    if (it == files_by_path_.end()) {
+      return Result("Virtual file '" + path + "' not found");
+    }
+    *content = it->second;
+    return {};
+  }
+
+ private:
+  std::unordered_map<std::string, std::string> files_by_path_;
+};
+
+}  // namespace amber
+
+#endif  // SRC_VIRTUAL_FILE_STORE_H_
diff --git a/src/virtual_file_store_test.cc b/src/virtual_file_store_test.cc
new file mode 100644
index 0000000..89e0320
--- /dev/null
+++ b/src/virtual_file_store_test.cc
@@ -0,0 +1,49 @@
+// Copyright 2020 The Amber Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/virtual_file_store.h"
+
+#include "gtest/gtest.h"
+
+namespace amber {
+
+TEST(VirtualFileStore, Canonical) {
+  ASSERT_EQ("a/b/c.e", VirtualFileStore::GetCanonical("a/b/c.e"));
+  ASSERT_EQ("a/b.c.e", VirtualFileStore::GetCanonical("a/b.c.e"));
+  ASSERT_EQ("a/b/c.e", VirtualFileStore::GetCanonical("a\\b\\c.e"));
+  ASSERT_EQ("a/b/c.e", VirtualFileStore::GetCanonical("./a/b/c.e"));
+}
+
+TEST(VirtualFileStore, AddGet) {
+  VirtualFileStore store;
+  store.Add("a/file.1", "File 1");
+  store.Add("./file.2", "File 2");
+  store.Add("b\\file.3", "File 3");
+
+  std::string content;
+  ASSERT_TRUE(store.Get("a/file.1", &content).IsSuccess());
+  ASSERT_EQ("File 1", content);
+
+  ASSERT_TRUE(store.Get("./file.2", &content).IsSuccess());
+  ASSERT_EQ("File 2", content);
+
+  ASSERT_TRUE(store.Get("b\\file.3", &content).IsSuccess());
+  ASSERT_EQ("File 3", content);
+
+  content = "<not-assigned>";
+  ASSERT_FALSE(store.Get("missing.file", &content).IsSuccess());
+  ASSERT_EQ("<not-assigned>", content);
+}
+
+}  // namespace amber
diff --git a/tests/cases/relative_includes_hlsl.amber b/tests/cases/relative_includes_hlsl.amber
new file mode 100644
index 0000000..dd91eea
--- /dev/null
+++ b/tests/cases/relative_includes_hlsl.amber
@@ -0,0 +1,56 @@
+#!amber
+# Copyright 2019 The Amber Authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+VIRTUAL_FILE "relative.hlsl"
+#error "Wrong include picked!"
+END
+
+VIRTUAL_FILE "subdir/relative.hlsl"
+// Correct include!
+struct VS_OUTPUT {
+  float4 pos : SV_POSITION;
+  float4 color : COLOR;
+};
+
+VS_OUTPUT main(float4 pos : POSITION,
+               float4 color : COLOR) {
+  VS_OUTPUT vout;
+  vout.pos = pos;
+  vout.color = color;
+  return vout;
+}
+END
+
+VIRTUAL_FILE "subdir/include.hlsl"
+#include "relative.hlsl"
+END
+
+VIRTUAL_FILE "main.hlsl"
+#include "subdir/include.hlsl"
+END
+
+SHADER vertex vtex_shader HLSL VIRTUAL_FILE main.hlsl
+
+SHADER fragment frag_shader HLSL
+float4 main(float4 color : COLOR) : SV_TARGET {
+  return color;
+}
+END
+
+PIPELINE graphics pipeline
+  ATTACH vtex_shader
+  ATTACH frag_shader
+END
diff --git a/tests/run_tests.py b/tests/run_tests.py
index d713a26..d908998 100755
--- a/tests/run_tests.py
+++ b/tests/run_tests.py
@@ -99,6 +99,7 @@
 
 DXC_CASES = [
   "draw_triangle_list_hlsl.amber",
+  "relative_includes_hlsl.amber",
 ]
 
 SUPPRESSIONS_DAWN = [