Skip to content

Commit 6a676e7

Browse files
Merge pull request #6 from OpacityLabs/Watch
Add watch command
2 parents 4f36a82 + 77ea7c3 commit 6a676e7

8 files changed

Lines changed: 279 additions & 190 deletions

File tree

.DS_Store

6 KB
Binary file not shown.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,4 @@ tokio = { version = "1.44.2", features = ["full"] }
2323
uuid = { version = "1.16.0", features = ["v4"] }
2424
chrono = "0.4.40"
2525
tracing-subscriber = "0.3.19"
26-
26+
notify = "6.1.1"

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ A command-line tool for bundling and analyzing Luau files using luau-lsp. This t
55
## Features
66

77
- **Bundling**: Bundle Luau files with configurable output formats
8+
- **Serve Mode**: Serve bundled flows over HTTP with optional file watching
89
- **Static Analysis**: Analyze Luau files using luau-lsp for type checking, linting, and code quality
910
- **Generate Completions**: Generate completions for the CLI
1011
- **Platform Organization**: Organize your Luau modules by platform and flows
@@ -31,6 +32,9 @@ opacity-cli bundle --config config.toml
3132

3233
# Analyze your Luau files with luau-lsp
3334
opacity-cli analyze --config config.toml
35+
36+
# Serve your Luau files, (--watch / -w) will auto bundle them on each save
37+
opacity-cli serve --watch
3438
```
3539

3640
### Analysis Features
@@ -56,4 +60,4 @@ Example:
5660

5761
```bash
5862
opacity-cli completions zsh > ~/.oh-my-zsh/completions/_opacity-cli
59-
```
63+
```

src/commands/analyze.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
use crate::config;
2+
use anyhow::{Context, Result};
3+
use which::which;
4+
5+
use std::env;
6+
7+
fn check_luau_lsp() -> Result<()> {
8+
which("luau-lsp").context("luau-lsp is not installed. Please install it first (https://github.com/JohnnyMorganz/luau-lsp/releases)")?;
9+
Ok(())
10+
}
11+
12+
13+
pub fn analyze(config_path: &str) -> Result<()> {
14+
check_luau_lsp()?;
15+
let config = config::Config::from_file(config_path)?;
16+
17+
let execution_dir = env::current_dir()?;
18+
19+
let definition_files = config.settings.definition_files.clone().unwrap_or_default();
20+
let definition_files_args = definition_files
21+
.iter()
22+
.flat_map(|file| ["--definitions".to_string(), file.to_string()])
23+
.collect::<Vec<String>>();
24+
25+
let file_paths = config.get_flows_paths();
26+
27+
let status = std::process::Command::new("luau-lsp")
28+
.stdout(std::process::Stdio::inherit())
29+
.current_dir(execution_dir.clone())
30+
.arg("analyze")
31+
.args(&definition_files_args)
32+
.args(&file_paths)
33+
.status()?;
34+
35+
if status.code().unwrap_or(-1) != 0 {
36+
anyhow::bail!("luau-lsp analysis failed");
37+
}
38+
39+
Ok(())
40+
}

src/commands/bundle.rs

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
use crate::config;
2+
use crate::config::{Flow, Platform};
3+
4+
use darklua_core::rules::bundle::BundleRequireMode;
5+
use darklua_core::rules::{
6+
InjectGlobalValue, RemoveCompoundAssignment, RemoveContinue, RemoveIfExpression, RemoveTypes,
7+
Rule,
8+
};
9+
use darklua_core::{
10+
process, BundleConfiguration, Configuration, GeneratorParameters, Options, Resources,
11+
};
12+
use std::path::PathBuf;
13+
use std::time::Instant;
14+
use anyhow::Result;
15+
use tracing::info;
16+
17+
18+
fn get_global_inject_rules(platform: &Platform, flow: &Flow) -> Vec<Box<dyn Rule>> {
19+
let mut rules: Vec<Box<dyn Rule>> = vec![
20+
Box::new(InjectGlobalValue::string("FLOW_NAME", flow.name.clone())),
21+
Box::new(InjectGlobalValue::string("FLOW_ALIAS", flow.alias.clone())),
22+
Box::new(InjectGlobalValue::string(
23+
"PLATFORM_NAME",
24+
platform.name.clone(),
25+
)),
26+
Box::new(InjectGlobalValue::string(
27+
"PLATFORM_DESCRIPTION",
28+
platform.description.clone(),
29+
)),
30+
];
31+
32+
if let Some(min_sdk_version) = &flow.min_sdk_version {
33+
rules.push(Box::new(InjectGlobalValue::string(
34+
"MIN_SDK_VERSION",
35+
min_sdk_version.clone(),
36+
)));
37+
}
38+
39+
if let Some(retrieves) = &flow.retrieves {
40+
rules.push(Box::new(InjectGlobalValue::string(
41+
"RETRIEVES",
42+
retrieves.join(", "),
43+
)));
44+
}
45+
46+
rules
47+
}
48+
49+
50+
fn process_bundle(resources: &Resources, options: Options) -> Result<()> {
51+
let process_start = Instant::now();
52+
let result =
53+
process(resources, options).map_err(|e| anyhow::anyhow!("Processing failed: {:?}", e))?;
54+
55+
match result.result() {
56+
Ok(_) => {
57+
println!("Successfully processed in {:?}", process_start.elapsed());
58+
Ok(())
59+
}
60+
Err(err) => {
61+
anyhow::bail!("Failed to process: {:?}", err);
62+
}
63+
}
64+
}
65+
66+
pub fn bundle(config_path: &str, is_rebundle: bool) -> Result<()> {
67+
let config = config::Config::from_file(config_path)?;
68+
let resources = Resources::from_file_system();
69+
70+
std::fs::create_dir_all(&config.settings.output_directory)?;
71+
72+
for platform in &config.platforms {
73+
println!("Processing platform: {}", platform.name);
74+
75+
for flow in &platform.flows {
76+
println!("Bundling {} ({})", flow.name, flow.alias);
77+
let input = PathBuf::from(&flow.path);
78+
79+
let output = PathBuf::from(&config.settings.output_directory)
80+
.join(format!("{}.bundle.lua", flow.alias));
81+
82+
let mut config = Configuration::empty();
83+
config = config.with_bundle_configuration(
84+
BundleConfiguration::new(BundleRequireMode::Path(Default::default()))
85+
.with_modules_identifier("__BUNDLE_MODULES"),
86+
);
87+
88+
let rules: Vec<Box<dyn Rule>> = vec![
89+
Box::new(RemoveContinue::default()),
90+
Box::new(RemoveCompoundAssignment::default()),
91+
Box::new(RemoveTypes::default()),
92+
Box::new(RemoveIfExpression::default()),
93+
];
94+
let rules = rules
95+
.into_iter()
96+
.chain(get_global_inject_rules(platform, flow))
97+
.collect::<Vec<Box<dyn Rule>>>();
98+
99+
for rule in rules {
100+
config = config.with_rule(rule);
101+
}
102+
103+
let options = Options::new(&input)
104+
.with_output(&output)
105+
.with_generator_override(GeneratorParameters::RetainLines)
106+
.with_configuration(config);
107+
108+
process_bundle(&resources, options)?;
109+
}
110+
}
111+
112+
if is_rebundle {
113+
info!("Rebundled all flows successfully");
114+
} else {
115+
info!("Bundled all flows successfully");
116+
}
117+
118+
Ok(())
119+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
use crate::Cli;
2+
use anyhow::Result;
3+
use clap::CommandFactory;
4+
5+
pub fn generate_completions(shell: &str) -> Result<()> {
6+
let mut app = Cli::command();
7+
8+
match shell {
9+
"bash" => clap_complete::generate(
10+
clap_complete::shells::Bash,
11+
&mut app,
12+
"opacity-cli",
13+
&mut std::io::stdout(),
14+
),
15+
"zsh" => clap_complete::generate(
16+
clap_complete::shells::Zsh,
17+
&mut app,
18+
"opacity-cli",
19+
&mut std::io::stdout(),
20+
),
21+
"fish" => clap_complete::generate(
22+
clap_complete::shells::Fish,
23+
&mut app,
24+
"opacity-cli",
25+
&mut std::io::stdout(),
26+
),
27+
"powershell" => clap_complete::generate(
28+
clap_complete::shells::PowerShell,
29+
&mut app,
30+
"opacity-cli",
31+
&mut std::io::stdout(),
32+
),
33+
_ => anyhow::bail!("Unsupported shell: {}", shell),
34+
}
35+
Ok(())
36+
}

src/commands/serve.rs

Lines changed: 62 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use crate::commands::bundle::bundle;
2+
13
use axum::{
24
extract::Query,
35
response::{IntoResponse, Response},
@@ -12,6 +14,15 @@ use tower_http::trace::{self, TraceLayer};
1214
use tracing::{info, Level};
1315
use uuid::Uuid;
1416

17+
use anyhow::Result;
18+
use std::path::Path;
19+
20+
use notify::event::{DataChange, EventKind, ModifyKind};
21+
use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher};
22+
use tokio::sync::mpsc;
23+
24+
25+
1526
#[derive(Deserialize)]
1627
struct FlowQuery {
1728
name: String,
@@ -34,7 +45,6 @@ struct SessionResponse {
3445
}
3546

3647
async fn read_flow(name: &str) -> Result<FlowResponse, String> {
37-
3848
let config = crate::config::Config::from_file("./opacity.toml").unwrap();
3949

4050
let matched_flow = config
@@ -44,15 +54,19 @@ async fn read_flow(name: &str) -> Result<FlowResponse, String> {
4454
.find(|flow| flow.alias == name)
4555
.ok_or_else(|| String::from("Flow not found"))?;
4656

47-
let script_path = PathBuf::from(config.settings.output_directory).join(format!("{}.bundle.lua", name));
57+
let script_path =
58+
PathBuf::from(config.settings.output_directory).join(format!("{}.bundle.lua", name));
4859
let script_content =
4960
fs::read_to_string(script_path).map_err(|_| String::from("Script file not found"))?;
5061

5162
Ok(FlowResponse {
5263
name: matched_flow.alias.clone(),
5364
min_sdk: match &matched_flow.min_sdk_version {
5465
None => {
55-
info!("No min SDK version found for flow {}; Defaulting to '1'", name);
66+
info!(
67+
"No min SDK version found for flow {}; Defaulting to '1'",
68+
name
69+
);
5670
"1".to_string()
5771
}
5872
Some(min_sdk) => min_sdk.clone(),
@@ -67,10 +81,9 @@ async fn flows(Query(query): Query<FlowQuery>) -> Response {
6781
Err(e) => {
6882
let (status, message) = match e.as_str() {
6983
"Flow not found" => (axum::http::StatusCode::NOT_FOUND, "Flow not found"),
70-
"Script file not found" => (
71-
axum::http::StatusCode::NOT_FOUND,
72-
"Script file not found",
73-
),
84+
"Script file not found" => {
85+
(axum::http::StatusCode::NOT_FOUND, "Script file not found")
86+
}
7487
_ => (
7588
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
7689
"Error processing flow request",
@@ -93,7 +106,33 @@ async fn sessions() -> Json<SessionResponse> {
93106
})
94107
}
95108

96-
pub async fn serve() -> Result<(), Box<dyn std::error::Error>> {
109+
async fn watch(config_path: &str) -> notify::Result<()> {
110+
let (tx, mut rx) = mpsc::channel::<Event>(100);
111+
112+
let mut watcher: RecommendedWatcher = notify::recommended_watcher(move |res| {
113+
if let Ok(event) = res {
114+
let _ = tx.try_send(event);
115+
} else if let Err(e) = res {
116+
eprintln!("Watch error: {:?}", e);
117+
}
118+
})?;
119+
120+
watcher.watch(Path::new("src"), RecursiveMode::Recursive)?;
121+
watcher.watch(Path::new(config_path), RecursiveMode::NonRecursive)?;
122+
info!("Watching all files in 'src' and '{}'", config_path);
123+
124+
while let Some(_event) = rx.recv().await {
125+
if _event.kind == EventKind::Modify(ModifyKind::Data(DataChange::Content)) {
126+
if let Err(err) = bundle(config_path, true) {
127+
tracing::error!("🟥 Rebundle failed: {:?}", err)
128+
}
129+
}
130+
}
131+
132+
Ok(())
133+
}
134+
135+
pub async fn serve(config_path: &str, should_watch: &bool) -> Result<(), Box<dyn std::error::Error>> {
97136
let port = 8080;
98137
let addr = SocketAddr::from(([0, 0, 0, 0], port));
99138

@@ -119,8 +158,21 @@ pub async fn serve() -> Result<(), Box<dyn std::error::Error>> {
119158

120159
info!("Listening on port {}...", port);
121160

122-
let listener = tokio::net::TcpListener::bind(addr).await?;
123-
axum::serve(listener, app.into_make_service()).await?;
161+
if *should_watch {
162+
tokio::try_join!(
163+
async {
164+
let listener = tokio::net::TcpListener::bind(addr).await?;
165+
axum::serve(listener, app.into_make_service()).await?;
166+
Ok::<_, Box<dyn std::error::Error>>(())
167+
},
168+
async {
169+
watch(config_path).await.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
170+
}
171+
)?;
172+
} else {
173+
let listener = tokio::net::TcpListener::bind(addr).await?;
174+
axum::serve(listener, app.into_make_service()).await?;
175+
}
124176

125177
Ok(())
126178
}

0 commit comments

Comments
 (0)