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: Option<PathBuf>,
19
20 #[clap(default_value = "stylua")]
21 #[arg(long)]
22 backend: FmtBackend,
23
24 #[arg(short, long, visible_short_alias = 'p')]
26 package: Option<PackageName>,
27}
28
29#[derive(clap::ValueEnum, Clone, Debug)]
30enum FmtBackend {
31 Stylua,
33 Luafmt,
37 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 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 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 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 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}