Fork of daniellemaywood.uk/gleam — Wasm codegen work
9.7 kB
270 lines
1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: 2022 The Gleam contributors
3
4#[cfg(test)]
5mod tests;
6
7use std::collections::{HashMap, HashSet};
8
9use camino::{Utf8Path, Utf8PathBuf};
10use ecow::{EcoString, eco_format};
11
12use crate::{
13 Error, Result,
14 io::{DirWalker, FileSystemReader, FileSystemWriter},
15 paths::ProjectPaths,
16};
17
18use super::package_compiler::CheckModuleConflicts;
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub(crate) struct CopiedNativeFiles {
22 pub any_elixir: bool,
23 pub to_compile: Vec<Utf8PathBuf>,
24}
25
26pub(crate) struct NativeFileCopier<'a, IO> {
27 io: IO,
28 paths: ProjectPaths,
29 destination_dir: &'a Utf8Path,
30 seen_native_files: HashSet<Utf8PathBuf>,
31 seen_modules: HashMap<EcoString, Utf8PathBuf>,
32 to_compile: Vec<Utf8PathBuf>,
33 elixir_files_copied: bool,
34 check_module_conflicts: CheckModuleConflicts,
35}
36
37impl<'a, IO> NativeFileCopier<'a, IO>
38where
39 IO: FileSystemReader + FileSystemWriter + Clone,
40{
41 pub(crate) fn new(
42 io: IO,
43 root: &'a Utf8Path,
44 out: &'a Utf8Path,
45 check_module_conflicts: CheckModuleConflicts,
46 ) -> Self {
47 Self {
48 io,
49 paths: ProjectPaths::new(root.into()),
50 destination_dir: out,
51 to_compile: Vec::new(),
52 seen_native_files: HashSet::new(),
53 seen_modules: HashMap::new(),
54 elixir_files_copied: false,
55 check_module_conflicts,
56 }
57 }
58
59 /// Copy native files from the given directory to the build directory.
60 ///
61 /// Errors if any duplicate files are found.
62 ///
63 /// Returns a list of files that need to be compiled (Elixir and Erlang).
64 ///
65 pub fn run(mut self) -> Result<CopiedNativeFiles> {
66 self.io.mkdir(&self.destination_dir)?;
67
68 let src = self.paths.src_directory();
69 self.copy_files(&src)?;
70
71 let test = self.paths.test_directory();
72 if self.io.is_directory(&test) {
73 self.copy_files(&test)?;
74 }
75
76 let dev = self.paths.dev_directory();
77 if self.io.is_directory(&dev) {
78 self.copy_files(&dev)?;
79 }
80
81 // Sort for deterministic output
82 self.to_compile.sort_unstable();
83
84 Ok(CopiedNativeFiles {
85 to_compile: self.to_compile,
86 any_elixir: self.elixir_files_copied,
87 })
88 }
89
90 fn copy_files(&mut self, src_root: &Utf8Path) -> Result<()> {
91 let mut dir_walker = DirWalker::new(src_root.to_path_buf());
92 while let Some(path) = dir_walker.next_file(&self.io)? {
93 self.copy(path, &src_root)?;
94 }
95 Ok(())
96 }
97
98 fn copy(&mut self, file: Utf8PathBuf, src_root: &Utf8Path) -> Result<()> {
99 let extension = file.extension().unwrap_or_default();
100
101 let relative_path = file
102 .strip_prefix(src_root)
103 .expect("copy_native_files strip prefix")
104 .to_path_buf();
105
106 // No need to run duplicate native file checks for .gleam files, but we
107 // still need to check for conflicting `.gleam` and `.mjs` files, so we
108 // add a special case for `.gleam`.
109 if extension == "gleam" {
110 self.check_for_conflicting_javascript_modules(&relative_path)?;
111 self.check_for_conflicting_erlang_modules(&relative_path)?;
112
113 return Ok(());
114 }
115
116 // Skip unknown file formats that are not supported native files
117 if !crate::io::is_native_file_extension(extension) {
118 return Ok(());
119 }
120
121 let destination = self.destination_dir.join(&relative_path);
122
123 // Check that this native file was not already copied
124 self.check_for_duplicate(&relative_path)?;
125
126 // Check for JavaScript modules conflicting between each other within
127 // the same relative path. We need to do this as '.gleam' files can
128 // also cause a conflict, despite not being native files, as they are
129 // compiled to `.mjs`.
130 self.check_for_conflicting_javascript_modules(&relative_path)?;
131
132 // Check for Erlang modules conflicting between each other anywhere in
133 // the tree.
134 self.check_for_conflicting_erlang_modules(&relative_path)?;
135
136 // If the source file's mtime is older than the destination file's mtime
137 // then it has not changed and as such does not need to be copied.
138 //
139 // This makes no practical difference for JavaScript etc files, but for
140 // Erlang and Elixir files it mean we can skip compiling them.
141 if self.io.is_file(&destination)
142 && self.io.modification_time(&file)? <= self.io.modification_time(&destination)?
143 {
144 tracing::debug!(?file, "skipping_unchanged_native_file_unchanged");
145 return Ok(());
146 }
147
148 tracing::debug!(?file, "copying_native_file");
149
150 // Ensure destination exists (subdir might not exist yet in the output)
151 if let Some(parent) = destination.parent() {
152 self.io.mkdir(parent)?;
153 }
154
155 self.io.copy(&file, &destination)?;
156 self.elixir_files_copied = self.elixir_files_copied || extension == "ex";
157
158 // BEAM native modules need to be compiled
159 if matches!(extension, "erl" | "ex") {
160 _ = self.to_compile.push(relative_path.clone());
161 }
162
163 Ok(())
164 }
165
166 fn check_for_duplicate(&mut self, relative_path: &Utf8PathBuf) -> Result<(), Error> {
167 if !self.seen_native_files.insert(relative_path.clone()) {
168 return Err(Error::DuplicateSourceFile {
169 file: relative_path.to_string(),
170 });
171 }
172 Ok(())
173 }
174
175 /// Gleam files are compiled to `.mjs` files, which must not conflict with
176 /// an FFI `.mjs` file with the same name, so we check for this case here.
177 fn check_for_conflicting_javascript_modules(
178 &mut self,
179 relative_path: &Utf8PathBuf,
180 ) -> Result<(), Error> {
181 let mjs_path = match relative_path.extension() {
182 Some("gleam") => eco_format!("{}", relative_path.with_extension("mjs")),
183 Some("mjs") => eco_format!("{}", relative_path),
184 _ => return Ok(()),
185 };
186
187 // Insert the full relative `.mjs` path in `seen_modules` as there is
188 // no conflict if two `.mjs` files have the same name but are in
189 // different subpaths, unlike Erlang files.
190 let existing = self
191 .seen_modules
192 .insert(mjs_path.clone(), relative_path.clone());
193
194 // If there was no already existing one then there's no problem.
195 let Some(existing) = existing else {
196 return Ok(());
197 };
198
199 let existing_is_gleam = existing.extension() == Some("gleam");
200 if existing_is_gleam || relative_path.extension() == Some("gleam") {
201 let (gleam_file, native_file) = if existing_is_gleam {
202 (&existing, relative_path)
203 } else {
204 (relative_path, &existing)
205 };
206 return Err(Error::ClashingGleamModuleAndNativeFileName {
207 module: eco_format!("{}", gleam_file.with_extension("")),
208 gleam_file: gleam_file.clone(),
209 native_file: native_file.clone(),
210 });
211 }
212
213 // The only way for two `.mjs` files to clash is by having
214 // the exact same path.
215 assert_eq!(&existing, relative_path);
216 return Err(Error::DuplicateSourceFile {
217 file: existing.to_string(),
218 });
219 }
220
221 /// Erlang module files cannot have the same name regardless of their
222 /// relative positions within the project. Ensure we raise an error if the
223 /// user attempts to create `.erl` files with the same name.
224 fn check_for_conflicting_erlang_modules(
225 &mut self,
226 relative_path: &Utf8PathBuf,
227 ) -> Result<(), Error> {
228 let erlang_module_name = match relative_path.extension() {
229 Some("erl") => {
230 eco_format!("{}", relative_path.file_name().expect("path has file name"))
231 }
232 Some("gleam") if self.check_module_conflicts.should_check() => relative_path
233 .with_extension("erl")
234 .as_str()
235 .replace("/", "@")
236 .into(),
237 _ => return Ok(()),
238 };
239
240 // Insert just the `.erl` module filename in `seen_modules` instead of
241 // its full relative path, because `.erl` files with the same name
242 // cause a conflict when targetting Erlang regardless of subpath.
243 if let Some(existing) = self
244 .seen_modules
245 .insert(erlang_module_name, relative_path.clone())
246 {
247 let existing_is_gleam = existing.extension() == Some("gleam");
248 if existing_is_gleam || relative_path.extension() == Some("gleam") {
249 let (gleam_file, native_file) = if existing_is_gleam {
250 (&existing, relative_path)
251 } else {
252 (relative_path, &existing)
253 };
254 return Err(Error::ClashingGleamModuleAndNativeFileName {
255 module: eco_format!("{}", gleam_file.with_extension("")),
256 gleam_file: gleam_file.clone(),
257 native_file: native_file.clone(),
258 });
259 }
260
261 return Err(Error::DuplicateNativeErlangModule {
262 module: eco_format!("{}", relative_path.file_stem().expect("path has file stem")),
263 first: existing,
264 second: relative_path.clone(),
265 });
266 }
267
268 Ok(())
269 }
270}