Skip to main content

lux_cli/
format.rs

1use std::path::{Path, PathBuf};
2
3use clap::Args;
4use emmylua_formatter as luafmt;
5use eyre::{bail, Context, Result};
6use lux_lib::{
7    config::Config, lua_version::LuaVersion, package::PackageName, project::Project,
8    workspace::Workspace,
9};
10use path_slash::PathExt;
11use walkdir::WalkDir;
12
13use crate::utils::path::{classify_path, PathTarget};
14
15#[derive(Args)]
16pub struct Fmt {
17    /// Path to a workspace, directory, or Lua file to format. Defaults to the current workspace.
18    path: Option<PathBuf>,
19
20    #[clap(default_value = "stylua")]
21    #[arg(long)]
22    backend: FmtBackend,
23
24    /// Package to format.
25    #[arg(short, long, visible_short_alias = 'p')]
26    package: Option<PackageName>,
27}
28
29#[derive(clap::ValueEnum, Clone, Debug)]
30enum FmtBackend {
31    /// Mainly follows the [Roblox Lua style guide](https://roblox.github.io/lua-style-guide/).
32    Stylua,
33    /// The default formatter used by [emmylua-analyzer-rust](https://github.com/EmmyLuaLs/emmylua-analyzer-rust).
34    /// If invoked with `lx --lua-version=<version> fmt`, Lux will configure the luafmt syntax level
35    /// to match the specified Lua version.
36    Luafmt,
37    /// The default formatter used by [lua-language-server](https://luals.github.io/).
38    EmmyluaCodestyle,
39}
40
41pub fn format(args: Fmt, config: Config) -> Result<()> {
42    let target = match args.path.as_deref() {
43        None => PathTarget::Workspace(Box::new(Workspace::current_or_err()?)),
44        Some(path) => classify_path(path)?,
45    };
46    match target {
47        PathTarget::Workspace(workspace) => {
48            if let Some(package) = &args.package {
49                let project = workspace.select_member(package)?;
50                format_project(&args, &workspace, project, &config)?;
51            } else {
52                for project in workspace.members() {
53                    format_project(&args, &workspace, project, &config)?;
54                }
55            }
56        }
57        PathTarget::File(file) => {
58            ensure_no_package(&args)?;
59            let root = file
60                .parent()
61                .unwrap_or_else(|| Path::new("."))
62                .to_path_buf();
63            format_loose(std::iter::once(file), &root, &args.backend, &config)?;
64        }
65        PathTarget::Directory(dir) => {
66            ensure_no_package(&args)?;
67            let files = WalkDir::new(&dir)
68                .into_iter()
69                .filter_map(Result::ok)
70                .filter(|entry| entry.file_type().is_file())
71                .map(|entry| entry.into_path())
72                .filter(|path| is_lua_source(path));
73            format_loose(files, &dir, &args.backend, &config)?;
74        }
75    }
76    Ok(())
77}
78
79struct FmtConfig {
80    stylua: stylua_lib::Config,
81    luafmt: luafmt::LuaFormatConfig,
82    luafmt_syntax_level: luafmt::LuaSyntaxLevel,
83    editorconfig: PathBuf,
84}
85
86impl FmtConfig {
87    fn resolve(root: &Path, lua_version: Option<LuaVersion>) -> Self {
88        let stylua: stylua_lib::Config = std::fs::read_to_string(root.join("stylua.toml"))
89            .or_else(|_| std::fs::read_to_string(root.join(".stylua.toml")))
90            .map(|config: String| toml::from_str(&config).unwrap_or_default())
91            .or_else(|_| {
92                stylua_lib::editorconfig::parse(stylua_lib::Config::new(), &root.join("*.lua"))
93            })
94            .unwrap_or_default();
95
96        let luafmt = luafmt::resolve_config_for_path(Some(root), None)
97            .map(|resolved| resolved.config)
98            .unwrap_or_default();
99        let luafmt_syntax_level = lua_version
100            .map(lua_version_to_luafmt_syntax_level)
101            .unwrap_or(luafmt.syntax.level);
102
103        Self {
104            stylua,
105            luafmt,
106            luafmt_syntax_level,
107            editorconfig: root.join(".editorconfig"),
108        }
109    }
110
111    fn format(&self, backend: &FmtBackend, path: &Path, code: &str) -> Result<String> {
112        Ok(match backend {
113            FmtBackend::Stylua => stylua_lib::format_code(
114                code,
115                self.stylua,
116                None,
117                stylua_lib::OutputVerification::Full,
118            )
119            .context(format!("error formatting {} with stylua.", path.display()))?,
120            FmtBackend::Luafmt => {
121                luafmt::check_text(code, self.luafmt_syntax_level.into(), &self.luafmt).formatted
122            }
123            FmtBackend::EmmyluaCodestyle => {
124                let uri = path.to_slash_lossy().to_string();
125                if self.editorconfig.is_file() {
126                    emmylua_codestyle::update_code_style(&uri, &self.editorconfig.to_slash_lossy());
127                }
128                emmylua_codestyle::reformat_code(
129                    code,
130                    &uri,
131                    emmylua_codestyle::FormattingOptions::default(),
132                )
133            }
134        })
135    }
136}
137
138fn format_files(
139    files: impl Iterator<Item = PathBuf>,
140    configs: &FmtConfig,
141    backend: &FmtBackend,
142) -> Result<()> {
143    files.into_iter().try_for_each(|file| {
144        let unformatted_code = std::fs::read_to_string(&file)?;
145        let formatted_code = configs.format(backend, &file, &unformatted_code)?;
146        std::fs::write(&file, formatted_code)
147            .context(format!("error writing formatted file {}.", file.display()))
148    })
149}
150
151fn format_project(
152    args: &Fmt,
153    workspace: &Workspace,
154    project: &Project,
155    config: &Config,
156) -> Result<()> {
157    let configs = FmtConfig::resolve(
158        workspace.root().as_ref(),
159        workspace.lua_version(config).ok(),
160    );
161
162    let lua_files = ["src", "lua", "lib", "spec", "test", "tests"]
163        .iter()
164        .flat_map(|dir| WalkDir::new(project.root().join(dir)))
165        .filter_map(Result::ok)
166        .map(walkdir::DirEntry::into_path)
167        .filter(|path| is_lua_source(path));
168
169    let rockspec = project.root().join("extra.rockspec");
170
171    format_files(
172        lua_files.chain(rockspec.exists().then_some(rockspec)),
173        &configs,
174        &args.backend,
175    )
176}
177
178fn is_lua_source(path: &Path) -> bool {
179    path.extension()
180        .is_some_and(|ext| ext == "lua" || ext == "rockspec")
181}
182
183fn ensure_no_package(args: &Fmt) -> Result<()> {
184    if args.package.is_some() {
185        bail!("--package is only valid within a workspace");
186    }
187    Ok(())
188}
189
190fn format_loose(
191    files: impl Iterator<Item = PathBuf>,
192    root: &Path,
193    backend: &FmtBackend,
194    config: &Config,
195) -> Result<()> {
196    let (config_root, lua_version) = match Workspace::from(root)? {
197        Some(workspace) => (
198            workspace.root().as_ref().to_path_buf(),
199            workspace.lua_version(config).ok(),
200        ),
201        None => (root.to_path_buf(), config.lua_version().cloned()),
202    };
203    let configs = FmtConfig::resolve(&config_root, lua_version);
204    format_files(files, &configs, backend)
205}
206
207fn lua_version_to_luafmt_syntax_level(lua_version: LuaVersion) -> luafmt::LuaSyntaxLevel {
208    match lua_version {
209        LuaVersion::Lua51 => luafmt::LuaSyntaxLevel::Lua51,
210        LuaVersion::Lua52 => luafmt::LuaSyntaxLevel::Lua52,
211        LuaVersion::Lua53 => luafmt::LuaSyntaxLevel::Lua53,
212        LuaVersion::Lua54 => luafmt::LuaSyntaxLevel::Lua54,
213        LuaVersion::Lua55 => luafmt::LuaSyntaxLevel::Lua55,
214        LuaVersion::LuaJIT | LuaVersion::LuaJIT52 => luafmt::LuaSyntaxLevel::LuaJIT,
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use assert_fs::fixture::PathChild;
221    use assert_fs::{prelude::PathCopy, TempDir};
222    use lux_lib::config::ConfigBuilder;
223    use serial_test::serial;
224
225    use super::*;
226    use std::path::PathBuf;
227
228    #[serial]
229    #[tokio::test]
230    async fn test_format_while_in_another_workspace() {
231        let unformatted_sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
232            .join("resources/test/sample-projects/unformatted/");
233        let unformatted_project_root = TempDir::new().unwrap();
234        unformatted_project_root
235            .copy_from(&unformatted_sample_project, &["**"])
236            .unwrap();
237
238        let cwd_sample_project =
239            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-projects/init/");
240        let cwd_project_root = TempDir::new().unwrap();
241        cwd_project_root
242            .copy_from(&cwd_sample_project, &["**"])
243            .unwrap();
244
245        let cwd = std::env::current_dir().unwrap();
246        std::env::set_current_dir(&cwd_project_root).unwrap();
247
248        let config = ConfigBuilder::new().unwrap().build().unwrap();
249        let fmt = Fmt {
250            path: Some(unformatted_project_root.to_path_buf()),
251            backend: FmtBackend::Stylua,
252            package: None,
253        };
254
255        format(fmt, config).unwrap();
256
257        let unformatted_file_path = unformatted_project_root.child("src").child("main.lua");
258        let content = std::fs::read_to_string(&unformatted_file_path).unwrap();
259
260        // the unformatted variant contains too many spaces
261        assert!(content.contains("print(1 * 2)"));
262
263        std::env::set_current_dir(&cwd).unwrap();
264    }
265
266    fn loose_lua_temp_dir() -> TempDir {
267        let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/loose-lua/");
268        let dir = TempDir::new().unwrap();
269        dir.copy_from(&fixture, &["**"]).unwrap();
270        dir
271    }
272
273    fn fmt(path: Option<PathBuf>) -> Fmt {
274        Fmt {
275            path,
276            backend: FmtBackend::Stylua,
277            package: None,
278        }
279    }
280
281    #[test]
282    fn test_format_plain_directory_without_lux_toml() {
283        let dir = loose_lua_temp_dir();
284        let config = ConfigBuilder::new().unwrap().build().unwrap();
285
286        format(fmt(Some(dir.to_path_buf())), config).unwrap();
287
288        let top = std::fs::read_to_string(dir.child("a.lua")).unwrap();
289        let nested = std::fs::read_to_string(dir.child("nested").child("b.lua")).unwrap();
290        let other = std::fs::read_to_string(dir.child("notes.txt")).unwrap();
291        assert!(top.contains("print(1 * 2)"));
292        assert!(nested.contains("print(3 + 4)"));
293        // non-Lua files are left untouched
294        assert!(other.contains("print( 5 *    6 )"));
295    }
296
297    #[test]
298    fn test_format_single_lua_file() {
299        let dir = loose_lua_temp_dir();
300        let config = ConfigBuilder::new().unwrap().build().unwrap();
301
302        format(fmt(Some(dir.child("a.lua").to_path_buf())), config).unwrap();
303
304        let top = std::fs::read_to_string(dir.child("a.lua")).unwrap();
305        let nested = std::fs::read_to_string(dir.child("nested").child("b.lua")).unwrap();
306        assert!(top.contains("print(1 * 2)"));
307        // a sibling file is not touched when a single file is targeted
308        assert!(nested.contains("print( 3 +    4 )"));
309    }
310
311    #[test]
312    fn test_format_nonexistent_path_errors() {
313        let config = ConfigBuilder::new().unwrap().build().unwrap();
314        let result = format(fmt(Some("/no/such/path".into())), config);
315        assert!(result.is_err());
316    }
317
318    #[test]
319    fn test_format_subdir_inherits_workspace_config() {
320        // must resolve workspace's stylua.toml (Spaces/2-width), not stylua default.
321        let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
322            .join("resources/test/sample-projects/stylua-config/");
323        let workspace = TempDir::new().unwrap();
324        workspace.copy_from(&fixture, &["**"]).unwrap();
325        let config = ConfigBuilder::new().unwrap().build().unwrap();
326
327        format(fmt(Some(workspace.child("src").to_path_buf())), config).unwrap();
328
329        let content = std::fs::read_to_string(workspace.child("src").child("main.lua")).unwrap();
330        assert!(content.contains("\n  print(1 * 2)"));
331        assert!(!content.contains('\t'));
332    }
333}