Skip to main content

lux_cli/
format.rs

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