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 #[arg(long)]
19 path: Option<PathBuf>,
20
21 #[clap(default_value = "stylua")]
22 #[arg(long)]
23 backend: FmtBackend,
24
25 #[arg(short, long, visible_short_alias = 'p')]
27 package: Option<PackageName>,
28}
29
30#[derive(clap::ValueEnum, Clone, Debug)]
31enum FmtBackend {
32 Stylua,
34 Luafmt,
38 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 .context(format!("error formatting {} with stylua.", path.display()))?,
121 FmtBackend::Luafmt => {
122 luafmt::check_text(code, self.luafmt_syntax_level.into(), &self.luafmt).formatted
123 }
124 FmtBackend::EmmyluaCodestyle => {
125 let uri = path.to_slash_lossy().to_string();
126 if self.editorconfig.is_file() {
127 emmylua_codestyle::update_code_style(&uri, &self.editorconfig.to_slash_lossy());
128 }
129 emmylua_codestyle::reformat_code(
130 code,
131 &uri,
132 emmylua_codestyle::FormattingOptions::default(),
133 )
134 }
135 })
136 }
137}
138
139fn format_files(
140 files: impl Iterator<Item = PathBuf>,
141 configs: &FmtConfig,
142 backend: &FmtBackend,
143) -> Result<()> {
144 files.into_iter().try_for_each(|file| {
145 let unformatted_code = std::fs::read_to_string(&file)?;
146 let formatted_code = configs.format(backend, &file, &unformatted_code)?;
147 std::fs::write(&file, formatted_code)
148 .context(format!("error writing formatted file {}.", file.display()))
149 })
150}
151
152fn format_project(
153 args: &Fmt,
154 workspace: &Workspace,
155 project: &Project,
156 config: &Config,
157) -> Result<()> {
158 let configs = FmtConfig::resolve(
159 workspace.root().as_ref(),
160 workspace.lua_version(config).ok(),
161 );
162
163 let lua_files = ["src", "lua", "lib", "spec", "test", "tests"]
164 .iter()
165 .flat_map(|dir| WalkDir::new(project.root().join(dir)))
166 .filter_map(Result::ok)
167 .map(walkdir::DirEntry::into_path)
168 .filter(|path| is_lua_source(path));
169
170 let rockspec = project.root().join("extra.rockspec");
171
172 format_files(
173 lua_files.chain(rockspec.exists().then_some(rockspec)),
174 &configs,
175 &args.backend,
176 )
177}
178
179fn is_lua_source(path: &Path) -> bool {
180 path.extension()
181 .is_some_and(|ext| ext == "lua" || ext == "rockspec")
182}
183
184fn ensure_no_package(args: &Fmt) -> Result<()> {
185 if args.package.is_some() {
186 bail!("--package is only valid within a workspace");
187 }
188 Ok(())
189}
190
191fn format_loose(
192 files: impl Iterator<Item = PathBuf>,
193 root: &Path,
194 backend: &FmtBackend,
195 config: &Config,
196) -> Result<()> {
197 let (config_root, lua_version) = match Workspace::from(root)? {
198 Some(workspace) => (
199 workspace.root().as_ref().to_path_buf(),
200 workspace.lua_version(config).ok(),
201 ),
202 None => (root.to_path_buf(), config.lua_version().cloned()),
203 };
204 let configs = FmtConfig::resolve(&config_root, lua_version);
205 format_files(files, &configs, backend)
206}
207
208fn lua_version_to_luafmt_syntax_level(lua_version: LuaVersion) -> luafmt::LuaSyntaxLevel {
209 match lua_version {
210 LuaVersion::Lua51 => luafmt::LuaSyntaxLevel::Lua51,
211 LuaVersion::Lua52 => luafmt::LuaSyntaxLevel::Lua52,
212 LuaVersion::Lua53 => luafmt::LuaSyntaxLevel::Lua53,
213 LuaVersion::Lua54 => luafmt::LuaSyntaxLevel::Lua54,
214 LuaVersion::Lua55 => luafmt::LuaSyntaxLevel::Lua55,
215 LuaVersion::LuaJIT | LuaVersion::LuaJIT52 => luafmt::LuaSyntaxLevel::LuaJIT,
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use assert_fs::fixture::PathChild;
222 use assert_fs::{prelude::PathCopy, TempDir};
223 use lux_lib::config::ConfigBuilder;
224 use serial_test::serial;
225
226 use super::*;
227 use std::path::PathBuf;
228
229 #[serial]
230 #[tokio::test]
231 async fn test_format_while_in_another_workspace() {
232 let unformatted_sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
233 .join("resources/test/sample-projects/unformatted/");
234 let unformatted_project_root = TempDir::new().unwrap();
235 unformatted_project_root
236 .copy_from(&unformatted_sample_project, &["**"])
237 .unwrap();
238
239 let cwd_sample_project =
240 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-projects/init/");
241 let cwd_project_root = TempDir::new().unwrap();
242 cwd_project_root
243 .copy_from(&cwd_sample_project, &["**"])
244 .unwrap();
245
246 let cwd = std::env::current_dir().unwrap();
247 std::env::set_current_dir(&cwd_project_root).unwrap();
248
249 let config = ConfigBuilder::new().unwrap().build().unwrap();
250 let fmt = Fmt {
251 path: Some(unformatted_project_root.to_path_buf()),
252 backend: FmtBackend::Stylua,
253 package: None,
254 };
255
256 format(fmt, config).unwrap();
257
258 let unformatted_file_path = unformatted_project_root.child("src").child("main.lua");
259 let content = std::fs::read_to_string(&unformatted_file_path).unwrap();
260
261 assert!(content.contains("print(1 * 2)"));
263
264 std::env::set_current_dir(&cwd).unwrap();
265 }
266
267 fn loose_lua_temp_dir() -> TempDir {
268 let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/loose-lua/");
269 let dir = TempDir::new().unwrap();
270 dir.copy_from(&fixture, &["**"]).unwrap();
271 dir
272 }
273
274 fn fmt(path: Option<PathBuf>) -> Fmt {
275 Fmt {
276 path,
277 backend: FmtBackend::Stylua,
278 package: None,
279 }
280 }
281
282 #[test]
283 fn test_format_plain_directory_without_lux_toml() {
284 let dir = loose_lua_temp_dir();
285 let config = ConfigBuilder::new().unwrap().build().unwrap();
286
287 format(fmt(Some(dir.to_path_buf())), config).unwrap();
288
289 let top = std::fs::read_to_string(dir.child("a.lua")).unwrap();
290 let nested = std::fs::read_to_string(dir.child("nested").child("b.lua")).unwrap();
291 let other = std::fs::read_to_string(dir.child("notes.txt")).unwrap();
292 assert!(top.contains("print(1 * 2)"));
293 assert!(nested.contains("print(3 + 4)"));
294 assert!(other.contains("print( 5 * 6 )"));
296 }
297
298 #[test]
299 fn test_format_single_lua_file() {
300 let dir = loose_lua_temp_dir();
301 let config = ConfigBuilder::new().unwrap().build().unwrap();
302
303 format(fmt(Some(dir.child("a.lua").to_path_buf())), config).unwrap();
304
305 let top = std::fs::read_to_string(dir.child("a.lua")).unwrap();
306 let nested = std::fs::read_to_string(dir.child("nested").child("b.lua")).unwrap();
307 assert!(top.contains("print(1 * 2)"));
308 assert!(nested.contains("print( 3 + 4 )"));
310 }
311
312 #[test]
313 fn test_format_nonexistent_path_errors() {
314 let config = ConfigBuilder::new().unwrap().build().unwrap();
315 let result = format(fmt(Some("/no/such/path".into())), config);
316 assert!(result.is_err());
317 }
318
319 #[test]
320 fn test_format_subdir_inherits_workspace_config() {
321 let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
323 .join("resources/test/sample-projects/stylua-config/");
324 let workspace = TempDir::new().unwrap();
325 workspace.copy_from(&fixture, &["**"]).unwrap();
326 let config = ConfigBuilder::new().unwrap().build().unwrap();
327
328 format(fmt(Some(workspace.child("src").to_path_buf())), config).unwrap();
329
330 let content = std::fs::read_to_string(workspace.child("src").child("main.lua")).unwrap();
331 assert!(content.contains("\n print(1 * 2)"));
332 assert!(!content.contains('\t'));
333 }
334}