Skip to main content

lux_lib/operations/
dist_bin.rs

1use std::{
2    io,
3    path::{Path, PathBuf},
4    process::Stdio,
5    sync::Arc,
6};
7
8use bon::Builder;
9use itertools::Itertools;
10use thiserror::Error;
11use walkdir::WalkDir;
12
13use crate::{
14    build::utils::c_dylib_extension,
15    config::Config,
16    lua_installation::{LuaInstallation, LuaInstallationError},
17    lua_rockspec::LuaModule,
18    operations::{InstallProject, InstallProjectError},
19    progress::{MultiProgress, Progress},
20    project::{project_toml::LocalProjectTomlValidationError, Project},
21    rockspec::Rockspec,
22    tree::{InstallTree, TreeError},
23};
24
25/// Compile a Lux project and all its dependencies into a single
26/// static binary that does not require a Lua installation.
27///
28/// Based on [luastatic](https://github.com/ers35/luastatic)
29#[derive(Builder)]
30#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
31pub struct DistProjectBin<'a, T>
32where
33    T: InstallTree,
34{
35    /// The project to compile.
36    project: &'a Project,
37
38    config: &'a Config,
39
40    /// Tree in which to install the project before compiling.
41    tree: &'a T,
42
43    /// Destination path for the compiled binary.
44    /// Defaults to `<cwd>/<package>[.exe]`.
45    output: Option<PathBuf>,
46
47    progress: Option<Arc<Progress<MultiProgress>>>,
48}
49
50use miette::Diagnostic;
51#[derive(Error, Debug, Diagnostic)]
52pub enum DistProjectBinError {
53    #[error("error installing project:\n{0}")]
54    #[diagnostic(forward(0))]
55    InstallProject(#[from] InstallProjectError),
56    #[error(transparent)]
57    #[diagnostic(transparent)]
58    LocalProjectTomlValidation(#[from] LocalProjectTomlValidationError),
59    #[error(transparent)]
60    #[diagnostic(transparent)]
61    Tree(#[from] TreeError),
62    #[cfg(not(target_os = "linux"))]
63    #[error(
64        r#"Lua binary libraries are only linkable on Linux.
65Cannot link the following binaries:
66{0}"#
67    )]
68    CannotLinkBinaryLibs(String),
69    #[error(transparent)]
70    #[diagnostic(transparent)]
71    LuaInstallation(#[from] LuaInstallationError),
72    #[error(transparent)]
73    CC(#[from] cc::Error),
74    #[error(transparent)]
75    Io(#[from] io::Error),
76    #[error("C compilation failed (exit {status}):\nstdout: {stdout}\nstderr: {stderr}")]
77    CompilationFailed {
78        status: std::process::ExitStatus,
79        stdout: String,
80        stderr: String,
81    },
82}
83
84impl<T, State> DistProjectBinBuilder<'_, T, State>
85where
86    T: InstallTree + Sync + Send + Clone + 'static,
87    State: dist_project_bin_builder::State + dist_project_bin_builder::IsComplete,
88{
89    pub async fn compile(self) -> Result<PathBuf, DistProjectBinError> {
90        do_dist_project_bin(self._build()).await
91    }
92}
93
94/// Files collected from an installed tree for binary compilation.
95#[derive(Debug)]
96struct InstalledFiles {
97    /// Lua source files (.lua) to embed.
98    src: Vec<(LuaModule, PathBuf)>,
99    /// Native Lua modules (.so/.dll/.dylib) to link.
100    lib: Vec<PathBuf>,
101}
102
103async fn do_dist_project_bin<T>(args: DistProjectBin<'_, T>) -> Result<PathBuf, DistProjectBinError>
104where
105    T: InstallTree + Sync + Send + Clone + 'static,
106{
107    let progress = args
108        .progress
109        .clone()
110        .unwrap_or_else(|| MultiProgress::new_arc(args.config));
111
112    let package = InstallProject::new()
113        .project(args.project)
114        .config(args.config)
115        .tree(args.tree)
116        .maybe_progress(args.progress)
117        .build()
118        .await?;
119
120    let files = collect_installed_files(args.tree)?;
121
122    let project_toml = args.project.toml().into_local()?;
123    let pkg_name = project_toml.package().to_string();
124
125    let entrypoint_module = project_toml
126        .run()
127        .and_then(|r| r.current_platform().args.as_ref())
128        .map(|args| args.first().as_str())
129        .map(entrypoint_stem)
130        .unwrap_or_else(|| pkg_name.clone());
131
132    let layout = args.tree.installed_rock_layout(&package)?;
133
134    let lua = LuaInstallation::new_from_config(args.config, &progress.map(|p| p.new_bar())).await?;
135
136    let output = args.output.unwrap_or_else(|| {
137        let mut p = PathBuf::from(&pkg_name);
138        if cfg!(target_env = "msvc") {
139            p.set_extension("exe");
140        }
141        p
142    });
143
144    let lib_root = layout.lib.clone();
145    let c_src = generate_c_source(&entrypoint_module, &files, &lib_root).await?;
146
147    let work_dir = tempfile::tempdir()?;
148    let c_path = work_dir.path().join(format!("{pkg_name}.static.c"));
149    tokio::fs::write(&c_path, &c_src).await?;
150
151    compile_binary(&c_path, &output, &lua, &files.lib, &work_dir, args.config).await?;
152
153    Ok(output)
154}
155
156#[allow(clippy::result_large_err)]
157fn collect_installed_files(tree: &impl InstallTree) -> Result<InstalledFiles, DistProjectBinError> {
158    let mut lua_sources = Vec::new();
159    let mut native_modules = Vec::new();
160    let c_dylib_ext = c_dylib_extension();
161
162    for package in tree.list()?.values().flatten() {
163        let layout = tree.installed_rock_layout(package)?;
164
165        if layout.src.is_dir() {
166            let src_canonical = layout.src.canonicalize().unwrap_or(layout.src.clone());
167            for path in WalkDir::new(&src_canonical)
168                .into_iter()
169                .filter_map(|e| e.ok())
170                .map(|e| e.into_path())
171                .filter(|p| p.is_file() && p.extension().is_some_and(|ext| ext == "lua"))
172            {
173                let rel = path
174                    .strip_prefix(&src_canonical)
175                    .unwrap_or(&path)
176                    .with_extension("");
177                if let Ok(module) = LuaModule::from_pathbuf(rel) {
178                    lua_sources.push((module, path));
179                }
180            }
181        }
182
183        if layout.lib.is_dir() {
184            let lib_canononical = layout.lib.canonicalize().unwrap_or(layout.lib.clone());
185            for path in WalkDir::new(&lib_canononical)
186                .into_iter()
187                .filter_map(|e| e.ok())
188                .map(|e| e.into_path())
189                .filter(|p| p.is_file() && p.extension().is_some_and(|ext| ext == c_dylib_ext))
190            {
191                native_modules.push(path);
192            }
193        }
194    }
195    #[cfg(not(target_os = "linux"))]
196    if !native_modules.is_empty() {
197        return Err(DistProjectBinError::CannotLinkBinaryLibs(
198            native_modules
199                .iter()
200                .unique()
201                .map(|p| p.to_string_lossy())
202                .join("\n"),
203        ));
204    }
205
206    // NOTE: A `FlatDistTree` can produce duplicates, as all modules share the same `src`.
207    Ok(InstalledFiles {
208        src: lua_sources.into_iter().unique().collect(),
209        lib: native_modules.into_iter().unique().collect(),
210    })
211}
212
213/// Derive a module stem from a run-spec arg like `"src/main.lua"` -> `"main"`.
214fn entrypoint_stem(arg: &str) -> String {
215    PathBuf::from(arg)
216        .file_stem()
217        .map(|s| s.to_string_lossy().into_owned())
218        .unwrap_or_else(|| arg.to_owned())
219}
220
221/// Derive the Lua module name from an installed lib path.
222/// e.g. `<tree>/lib/foo/bar.so` -> `("foo.bar", "foo_bar")`.
223fn module_names(lib_root: &Path, path: &Path) -> (String, String) {
224    let rel = path.strip_prefix(lib_root).unwrap_or(path);
225    let dotpath = rel
226        .with_extension("")
227        .to_string_lossy()
228        .replace(std::path::MAIN_SEPARATOR, ".");
229    let underscore = dotpath.replace(['.', '-'], "_");
230    (dotpath, underscore)
231}
232
233/// Generate a C source file embedding all Lua sources.
234async fn generate_c_source(
235    entrypoint_module: &str,
236    files: &InstalledFiles,
237    lib_root: &Path,
238) -> Result<String, io::Error> {
239    let mut out = String::from(C_PREAMBLE);
240
241    out.push_str("#ifdef __cplusplus\nextern \"C\" {\n#endif\n");
242    for path in &files.lib {
243        let (_, underscore) = module_names(lib_root, path);
244        out.push_str(&format!("int luaopen_{underscore}(lua_State *L);\n"));
245    }
246    out.push_str("#ifdef __cplusplus\n}\n#endif\n\n");
247
248    for (i, (_, path)) in files.src.iter().enumerate() {
249        let bytes = tokio::fs::read(path).await.map_err(|err| {
250            io::Error::other(format!("unable to read '{}': {err}", path.display()))
251        })?;
252        let hex = bytes_to_hex(&bytes);
253        out.push_str(&format!(
254            "static const unsigned char lua_src_{i}[] = {{{hex}}};\n"
255        ));
256    }
257
258    let loader_with_entrypoint = format!(
259        "{LUA_LOADER_SOURCE}local func = lua_loader(\"{entrypoint_module}\")\n\
260         if type(func) == \"function\" then\n\
261         \tfunc(unpack(arg))\n\
262         else\n\
263         \terror(func, 0)\n\
264         end\n"
265    );
266    let loader_hex = bytes_to_hex(loader_with_entrypoint.as_bytes());
267    out.push_str(&format!(
268        "static const unsigned char lua_loader_program[] = {{{loader_hex}}};\n\n"
269    ));
270
271    out.push_str("int main(int argc, char *argv[]) {\n");
272    out.push_str("  lua_State *L = luaL_newstate();\n");
273    out.push_str("  luaL_openlibs(L);\n");
274    out.push_str("  createargtable(L, argv, argc, 0);\n\n");
275
276    out.push_str(&format!(
277        "  if (luaL_loadbuffer(L, (const char*)lua_loader_program, sizeof(lua_loader_program), \"{entrypoint_module}\") != LUA_OK) {{\n"
278    ));
279    out.push_str("    fprintf(stderr, \"luaL_loadbuffer: %s\\n\", lua_tostring(L, -1));\n");
280    out.push_str("    lua_close(L); return 1;\n  }\n\n");
281
282    out.push_str("  /* lua_bundle */\n  lua_newtable(L);\n");
283
284    for (i, (module, _)) in files.src.iter().enumerate() {
285        out.push_str(&format!(
286            "  lua_pushlstring(L, (const char*)lua_src_{i}, sizeof(lua_src_{i}));\n"
287        ));
288        out.push_str(&format!("  lua_setfield(L, -2, \"{module}\");\n"));
289    }
290
291    for path in &files.lib {
292        let (dotpath, underscore) = module_names(lib_root, path);
293        out.push_str(&format!("  lua_pushcfunction(L, luaopen_{underscore});\n"));
294        out.push_str(&format!("  lua_setfield(L, -2, \"{dotpath}\");\n"));
295    }
296
297    out.push_str("\n  if (docall(L, 1, LUA_MULTRET)) {\n");
298    out.push_str("    const char *msg = lua_tostring(L, 1);\n");
299    out.push_str("    if (msg) fprintf(stderr, \"%s\\n\", msg);\n");
300    out.push_str("    lua_close(L); return 1;\n  }\n");
301    out.push_str("  lua_close(L);\n  return 0;\n}\n");
302
303    Ok(out)
304}
305
306fn bytes_to_hex(bytes: &[u8]) -> String {
307    bytes
308        .iter()
309        .map(|b| format!("0x{b:02x}"))
310        .collect::<Vec<_>>()
311        .join(", ")
312}
313
314async fn compile_binary(
315    c_path: &Path,
316    output: &Path,
317    lua: &LuaInstallation,
318    native_modules: &[PathBuf],
319    work_dir: &tempfile::TempDir,
320    config: &Config,
321) -> Result<(), DistProjectBinError> {
322    let mut build = cc::Build::new();
323    let host = target_lexicon::Triple::host().to_string();
324
325    let intermediate_dir = tempfile::tempdir()?;
326    build
327        .cargo_output(false)
328        .cargo_metadata(false)
329        .cargo_warnings(false)
330        .warnings(config.verbose())
331        .host(&host)
332        .target(&host)
333        .opt_level(3)
334        .out_dir(&intermediate_dir);
335
336    let compiler = build.try_get_compiler()?;
337
338    let is_msvc = compiler.is_like_msvc();
339    // Suppress all warnings
340    if is_msvc {
341        build.flag("-W0");
342    } else {
343        build.flag("-w");
344    }
345
346    let mut cmd: tokio::process::Command = compiler.to_command().into();
347    cmd.current_dir(work_dir.path());
348    cmd.arg(c_path);
349
350    for include in lua.includes() {
351        cmd.arg(format!("-I{}", include.display()));
352    }
353    cmd.args(native_modules);
354    cmd.arg("-o").arg(output);
355    cmd.args(lua.lib_link_args(&compiler));
356
357    #[cfg(not(target_env = "msvc"))]
358    {
359        cmd.arg("-rdynamic");
360        cmd.arg("-lm");
361        // Link with libdl because liblua was built with support loading
362        // shared objects and the operating system depends on it.
363        #[cfg(target_family = "unix")]
364        cmd.arg("-ldl");
365    }
366
367    let out = cmd
368        .stdout(Stdio::piped())
369        .stderr(Stdio::piped())
370        .output()
371        .await?;
372
373    if !out.status.success() {
374        return Err(DistProjectBinError::CompilationFailed {
375            status: out.status,
376            stdout: String::from_utf8_lossy(&out.stdout).into(),
377            stderr: String::from_utf8_lossy(&out.stderr).into(),
378        });
379    }
380
381    Ok(())
382}
383
384const C_PREAMBLE: &str = r#"
385#ifdef __cplusplus
386extern "C" {
387#endif
388#include <lauxlib.h>
389#include <lua.h>
390#include <lualib.h>
391#ifdef __cplusplus
392}
393#endif
394#include <signal.h>
395#include <stdio.h>
396#include <stdlib.h>
397#include <string.h>
398
399#if LUA_VERSION_NUM == 501
400  #define LUA_OK 0
401#endif
402
403static lua_State *globalL = NULL;
404
405static void lstop(lua_State *L, lua_Debug *ar) {
406  (void)ar;
407  lua_sethook(L, NULL, 0, 0);
408  luaL_error(L, "interrupted!");
409}
410
411static void laction(int i) {
412  signal(i, SIG_DFL);
413  lua_sethook(globalL, lstop, LUA_MASKCALL | LUA_MASKRET | LUA_MASKCOUNT, 1);
414}
415
416static void createargtable(lua_State *L, char **argv, int argc, int script) {
417  int i, narg;
418  if (script == argc) script = 0;
419  narg = argc - (script + 1);
420  lua_createtable(L, narg, script + 1);
421  for (i = 0; i < argc; i++) {
422    lua_pushstring(L, argv[i]);
423    lua_rawseti(L, -2, i - script);
424  }
425  lua_setglobal(L, "arg");
426}
427
428static int msghandler(lua_State *L) {
429  const char *msg = lua_tostring(L, 1);
430  if (msg == NULL) {
431    if (luaL_callmeta(L, 1, "__tostring") && lua_type(L, -1) == LUA_TSTRING)
432      return 1;
433    msg = lua_pushfstring(L, "(error object is a %s value)", luaL_typename(L, 1));
434  }
435  lua_getglobal(L, "debug");
436  lua_getfield(L, -1, "traceback");
437  lua_remove(L, -2);
438  lua_pushstring(L, msg);
439  lua_remove(L, -3);
440  lua_pushinteger(L, 2);
441  lua_call(L, 2, 1);
442  return 1;
443}
444
445static int docall(lua_State *L, int narg, int nres) {
446  int status;
447  int base = lua_gettop(L) - narg;
448  lua_pushcfunction(L, msghandler);
449  lua_insert(L, base);
450  globalL = L;
451  signal(SIGINT, laction);
452  status = lua_pcall(L, narg, nres, base);
453  signal(SIGINT, SIG_DFL);
454  lua_remove(L, base);
455  return status;
456}
457
458"#;
459
460const LUA_LOADER_SOURCE: &str = r#"local args = {...}
461local lua_bundle = args[1]
462
463local function load_string(str, name)
464	if _VERSION == "Lua 5.1" then
465		return loadstring(str, name)
466	else
467		return load(str, name)
468	end
469end
470
471local function lua_loader(name)
472	local separator = package.config:sub(1, 1)
473	name = name:gsub(separator, ".")
474	local mod = lua_bundle[name] or lua_bundle[name .. ".init"]
475	if mod then
476		if type(mod) == "string" then
477			local chunk, errstr = load_string(mod, name)
478			if chunk then
479				return chunk
480			else
481				error(
482					("error loading module '%s' from static Lua bundle:\n\t%s"):format(name, errstr),
483					0
484				)
485			end
486		elseif type(mod) == "function" then
487			return mod
488		end
489	else
490		return ("\n\tno module '%s' in static Lua bundle"):format(name)
491	end
492end
493table.insert(package.loaders or package.searchers, 2, lua_loader)
494
495local unpack = unpack or table.unpack
496"#;
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501
502    use assert_fs::fixture::PathCopy;
503    #[cfg(target_os = "linux")]
504    use assert_fs::prelude::{PathChild, PathCreateDir};
505    use assert_fs::TempDir;
506
507    use crate::lua_installation::detect_installed_lua_version;
508    use crate::{config::ConfigBuilder, lua_version::LuaVersion, tree::FlatDistTree};
509    #[cfg(target_os = "linux")]
510    use crate::{
511        lockfile::{LocalPackage, LocalPackageHashes, LockConstraint},
512        package::PackageSpec,
513        remote_package_source::RemotePackageSource,
514        rockspec::RockBinaries,
515    };
516
517    #[cfg(target_os = "linux")]
518    fn mk_dummy_package(spec: PackageSpec) -> LocalPackage {
519        let hashes = LocalPackageHashes {
520            rockspec: "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
521                .parse()
522                .unwrap(),
523            source: "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
524                .parse()
525                .unwrap(),
526        };
527        LocalPackage::from(
528            &spec,
529            LockConstraint::Unconstrained,
530            RockBinaries::default(),
531            RemotePackageSource::Test,
532            None,
533            hashes,
534        )
535    }
536
537    #[cfg(target_os = "linux")]
538    #[tokio::test]
539    async fn test_collect_installed_files() {
540        let staging = TempDir::new().unwrap();
541        let config = ConfigBuilder::new()
542            .unwrap()
543            .lua_version(Some(LuaVersion::Lua51))
544            .build()
545            .unwrap();
546        let tree = FlatDistTree::new(staging.to_path_buf(), LuaVersion::Lua51, &config).unwrap();
547
548        let pkg_a = mk_dummy_package(PackageSpec::new("foo".into(), "1.0.0-1".parse().unwrap()));
549        let layout_a = tree.entrypoint(&pkg_a).unwrap();
550        staging
551            .child(layout_a.src.strip_prefix(staging.path()).unwrap())
552            .create_dir_all()
553            .unwrap();
554        tokio::fs::write(layout_a.src.join("foo.lua"), "return {}")
555            .await
556            .unwrap();
557
558        let pkg_b = mk_dummy_package(PackageSpec::new("bar".into(), "2.0.0-1".parse().unwrap()));
559        let layout_b = tree.entrypoint(&pkg_b).unwrap();
560        staging
561            .child(layout_b.src.strip_prefix(staging.path()).unwrap())
562            .create_dir_all()
563            .unwrap();
564        tokio::fs::write(layout_b.src.join("bar.lua"), "return {}")
565            .await
566            .unwrap();
567        staging
568            .child(layout_b.lib.strip_prefix(staging.path()).unwrap())
569            .create_dir_all()
570            .unwrap();
571        tokio::fs::write(
572            layout_b.lib.join(format!("bar.{}", c_dylib_extension())),
573            "",
574        )
575        .await
576        .unwrap();
577
578        {
579            let lockfile = tree.lockfile().unwrap();
580            let mut lockfile = lockfile.write_guard();
581            lockfile.add_entrypoint(&pkg_a);
582            lockfile.add_entrypoint(&pkg_b);
583        }
584
585        let tree = FlatDistTree::new(staging.to_path_buf(), LuaVersion::Lua51, &config).unwrap();
586        let files = collect_installed_files(&tree).unwrap();
587
588        assert_eq!(files.src.len(), 2);
589        assert!(files
590            .src
591            .iter()
592            .all(|(_, p)| p.extension().is_some_and(|e| e == "lua")));
593        assert_eq!(files.lib.len(), 1);
594        assert!(files.lib[0]
595            .extension()
596            .is_some_and(|e| e == c_dylib_extension()));
597    }
598
599    #[tokio::test]
600    async fn test_collect_installed_files_from_sample_project() {
601        let sample = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
602            .join("resources/test/sample-projects/only-src/");
603        let project_dir = TempDir::new().unwrap();
604        project_dir.copy_from(&sample, &["**"]).unwrap();
605
606        let project = Project::from_exact(project_dir.path()).unwrap().unwrap();
607        let lua_version = detect_installed_lua_version().or(Some(LuaVersion::Lua51));
608        let config = ConfigBuilder::new()
609            .unwrap()
610            .lua_version(lua_version)
611            .build()
612            .unwrap();
613
614        let staging = TempDir::new().unwrap();
615        let tree = FlatDistTree::new(
616            staging.to_path_buf(),
617            config.lua_version().cloned().unwrap(),
618            &config,
619        )
620        .unwrap();
621
622        InstallProject::new()
623            .project(&project)
624            .config(&config)
625            .tree(&tree)
626            .build()
627            .await
628            .unwrap();
629
630        let files = collect_installed_files(&tree).unwrap();
631
632        let module_keys: Vec<&str> = files.src.iter().map(|(m, _)| m.as_str()).collect();
633
634        assert!(
635            module_keys.contains(&"main"),
636            "expected 'main' in {module_keys:?}"
637        );
638        assert!(
639            module_keys.contains(&"foo"),
640            "expected 'foo' in {module_keys:?}"
641        );
642    }
643
644    #[tokio::test]
645    async fn test_dist_bin_from_lua_source_compiles_and_runs() {
646        test_dist_bin_compiles_and_runs("resources/test/sample-projects/only-src/", "1").await
647    }
648
649    #[tokio::test]
650    #[cfg(target_os = "linux")]
651    async fn test_dist_bin_from_c_source_compiles_and_runs() {
652        test_dist_bin_compiles_and_runs("resources/test/sample-projects/c-src/", "OK").await
653    }
654
655    async fn test_dist_bin_compiles_and_runs(sample_project_path: &str, expected_output: &str) {
656        let sample = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(sample_project_path);
657        let project_dir = TempDir::new().unwrap();
658        project_dir.copy_from(&sample, &["**"]).unwrap();
659
660        let project = Project::from_exact(project_dir.path()).unwrap().unwrap();
661        let lua_version = detect_installed_lua_version().or(Some(LuaVersion::Lua51));
662        let config = ConfigBuilder::new()
663            .unwrap()
664            .lua_version(lua_version)
665            .build()
666            .unwrap();
667
668        let staging = TempDir::new().unwrap();
669        let tree = FlatDistTree::new(
670            staging.to_path_buf(),
671            config.lua_version().cloned().unwrap(),
672            &config,
673        )
674        .unwrap();
675
676        let out_dir = TempDir::new().unwrap();
677        let binary = out_dir.path().join(if cfg!(target_env = "msvc") {
678            "sample-project.exe"
679        } else {
680            "sample-project"
681        });
682
683        DistProjectBin::new()
684            .project(&project)
685            .config(&config)
686            .tree(&tree)
687            .output(binary.clone())
688            .compile()
689            .await
690            .unwrap();
691
692        assert!(binary.is_file(), "binary not produced");
693
694        let out = tokio::process::Command::new(&binary)
695            .output()
696            .await
697            .unwrap();
698
699        assert!(out.status.success(), "binary exited non-zero:\n{:?}", out);
700        assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), expected_output);
701    }
702}