use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{bail, Context, Result};
use serde_json::{json, Value};
use crate::config::PackageWorkflow;
use crate::steps::probe;
use crate::ui::{self, Tally};
enum CodeInvocation {
OnPath,
Cmd(PathBuf),
}
fn locate_code() -> Result<CodeInvocation> {
if probe("code", &["--version"]) {
return Ok(CodeInvocation::OnPath);
}
let local_app_data = std::env::var_os("LOCALAPPDATA").context("LOCALAPPDATA is not set")?;
let candidate = PathBuf::from(local_app_data)
.join("Programs")
.join("Microsoft VS Code")
.join("bin")
.join("code.cmd");
if candidate.is_file() {
Ok(CodeInvocation::Cmd(candidate))
} else {
bail!(
"code isn't on PATH and no install was found at {} - is VS Code installed?",
candidate.display()
)
}
}
fn run_code(args: &[&str]) -> Result<std::process::Output> {
match locate_code()? {
CodeInvocation::OnPath => Command::new("code").args(args).output().context("failed to spawn `code`"),
CodeInvocation::Cmd(path) => {
let mut cmd = Command::new("cmd");
cmd.arg("/C").arg(&path).args(args);
cmd.output().with_context(|| format!("failed to spawn `{}` via cmd.exe", path.display()))
}
}
}
fn installed_extensions() -> HashSet<String> {
run_code(&["--list-extensions"])
.ok()
.filter(|o| o.status.success())
.map(|o| {
String::from_utf8_lossy(&o.stdout)
.lines()
.map(|l| l.trim().to_lowercase())
.collect()
})
.unwrap_or_default()
}
pub fn ensure_extensions(extension_ids: &[&str]) -> Result<()> {
locate_code()?;
let installed = installed_extensions();
let mut tally = Tally::new();
for id in extension_ids {
if installed.contains(&id.to_lowercase()) {
tally.already(id);
continue;
}
match install_extension(id) {
Ok(()) => tally.did(id),
Err(err) => ui::warn(&format!("VS Code extension {id} skipped - {err}")),
}
}
tally.finish("VS Code extensions");
Ok(())
}
fn install_extension(id: &str) -> Result<()> {
let output = run_code(&["--install-extension", id])
.with_context(|| format!("failed to spawn install for {id}"))?;
if !output.stdout.is_empty() {
print!("{}", String::from_utf8_lossy(&output.stdout));
}
if !output.status.success() {
ui::passthrough(
&String::from_utf8_lossy(&output.stdout),
&String::from_utf8_lossy(&output.stderr),
);
bail!("exited with {}", output.status);
}
Ok(())
}
pub fn read_settings(project_dir: &Path) -> Result<serde_json::Map<String, Value>> {
let path = project_dir.join(".vscode").join("settings.json");
if !path.exists() {
return Ok(serde_json::Map::new());
}
let text = fs::read_to_string(&path)?;
if text.trim().is_empty() {
return Ok(serde_json::Map::new());
}
match serde_json::from_str::<Value>(&text) {
Ok(Value::Object(map)) => Ok(map),
Ok(_) => bail!("{} exists but isn't a JSON object", path.display()),
Err(err) => bail!(
"could not parse {} ({err}). It may contain comments or trailing commas, \
which this writer can't preserve - fix or move the file, then re-run.",
path.display()
),
}
}
pub fn merge_settings(project_dir: &Path, entries: &[(&str, Value)]) -> Result<()> {
let merged = merged_settings(project_dir, entries)?;
let dir = project_dir.join(".vscode");
fs::create_dir_all(&dir)?;
let path = dir.join("settings.json");
fs::write(&path, merged).with_context(|| format!("failed to write {}", path.display()))?;
ui::ok("wrote .vscode/settings.json");
Ok(())
}
pub fn merged_settings(project_dir: &Path, entries: &[(&str, Value)]) -> Result<String> {
let mut settings = read_settings(project_dir)?;
for (key, value) in entries {
settings.insert((*key).to_string(), value.clone());
}
drop_superseded(&mut settings);
Ok(format!("{}\n", serde_json::to_string_pretty(&Value::Object(settings))?))
}
const SUPERSEDED: &[(&str, &str)] = &[
("luau-lsp.plugin.enabled", "luau-lsp.studioPlugin.enabled"),
("luau-lsp.types.roblox", "luau-lsp.platform.type"),
];
fn drop_superseded(settings: &mut serde_json::Map<String, Value>) {
for (old, replacement) in SUPERSEDED {
if settings.contains_key(*replacement) {
settings.remove(*old);
}
}
}
const VENDORED_GLOBS: &[&str] = &["**/_Index/**", "**/submodules/**"];
pub fn ensure_project_settings(project_dir: &Path, workflow: PackageWorkflow) -> Result<()> {
merge_settings(project_dir, &project_settings(workflow))
}
pub fn project_settings(workflow: PackageWorkflow) -> Vec<(&'static str, Value)> {
let mut entries: Vec<(&str, Value)> = vec![
("luau-lsp.studioPlugin.enabled", json!(true)),
("luau-lsp.sourcemap.enabled", json!(true)),
("luau-lsp.sourcemap.autogenerate", json!(false)),
("luau-lsp.sourcemap.rojoProjectFile", json!("default.project.json")),
("luau-lsp.sourcemap.sourcemapFile", json!("sourcemap.json")),
("luau-lsp.platform.type", json!("roblox")),
("files.eol", json!("\n")),
];
if let Some(path) = rokit_tool_path("stylua") {
entries.push(("stylua.styluaPath", json!(path)));
}
entries.push(("stylua.configPath", json!("")));
if workflow == PackageWorkflow::GitSubmodules {
entries.push(("luau-lsp.ignoreGlobs", json!(VENDORED_GLOBS)));
entries.push(("luau-lsp.completion.imports.ignoreGlobs", json!(VENDORED_GLOBS)));
}
entries
}
fn rokit_tool_path(tool: &str) -> Option<String> {
let home = directories::UserDirs::new()?.home_dir().to_path_buf();
let shim = home.join(".rokit").join("bin").join(format!("{tool}.exe"));
shim.is_file().then(|| shim.display().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vendored_globs_extend_rather_than_replace_the_defaults() {
assert!(VENDORED_GLOBS.contains(&"**/_Index/**"), "must keep Wally's vendored dir");
assert!(VENDORED_GLOBS.contains(&"**/submodules/**"), "must add the submodule dir");
}
fn setting(workflow: PackageWorkflow, key: &str) -> Option<Value> {
project_settings(workflow).into_iter().find(|(k, _)| *k == key).map(|(_, v)| v)
}
#[test]
fn both_workflows_get_editor_settings() {
for workflow in [PackageWorkflow::Wally, PackageWorkflow::GitSubmodules] {
assert!(!project_settings(workflow).is_empty(), "{workflow:?} got nothing");
}
}
#[test]
fn the_studio_plugin_bridge_is_turned_on() {
for workflow in [PackageWorkflow::Wally, PackageWorkflow::GitSubmodules] {
assert_eq!(setting(workflow, "luau-lsp.studioPlugin.enabled"), Some(json!(true)));
assert!(setting(workflow, "luau-lsp.plugin.enabled").is_none(), "deprecated spelling");
}
}
#[test]
fn a_stale_global_stylua_config_path_is_neutralised() {
assert_eq!(setting(PackageWorkflow::Wally, "stylua.configPath"), Some(json!("")));
}
#[test]
fn a_superseded_setting_is_removed_once_its_replacement_lands() {
let mut settings = serde_json::Map::new();
settings.insert("luau-lsp.plugin.enabled".into(), json!(true));
settings.insert("luau-lsp.studioPlugin.enabled".into(), json!(true));
settings.insert("editor.rulers".into(), json!([100]));
drop_superseded(&mut settings);
assert!(!settings.contains_key("luau-lsp.plugin.enabled"));
assert!(settings.contains_key("luau-lsp.studioPlugin.enabled"));
assert!(settings.contains_key("editor.rulers"), "unrelated keys must survive");
}
#[test]
fn a_superseded_setting_survives_until_its_replacement_exists() {
let mut settings = serde_json::Map::new();
settings.insert("luau-lsp.plugin.enabled".into(), json!(true));
drop_superseded(&mut settings);
assert_eq!(settings.get("luau-lsp.plugin.enabled"), Some(&json!(true)));
}
#[test]
fn every_replacement_is_a_setting_rproj_writes() {
let written: Vec<&str> =
project_settings(PackageWorkflow::Wally).into_iter().map(|(k, _)| k).collect();
for (old, replacement) in SUPERSEDED {
assert!(written.contains(replacement), "{old} -> {replacement} is never written");
assert!(!written.contains(old), "{old} is deprecated and must not be written");
}
}
#[test]
fn new_files_are_created_with_unix_line_endings() {
assert_eq!(setting(PackageWorkflow::Wally, "files.eol"), Some(json!("
")));
}
#[test]
fn the_deprecated_roblox_types_switch_is_not_written() {
assert!(setting(PackageWorkflow::Wally, "luau-lsp.types.roblox").is_none());
assert_eq!(setting(PackageWorkflow::Wally, "luau-lsp.platform.type"), Some(json!("roblox")));
}
#[test]
fn sourcemap_autogeneration_is_left_to_rproj_watch() {
assert_eq!(
setting(PackageWorkflow::Wally, "luau-lsp.sourcemap.autogenerate"),
Some(json!(false))
);
}
#[test]
fn vendored_globs_stay_submodule_only() {
assert!(setting(PackageWorkflow::Wally, "luau-lsp.ignoreGlobs").is_none());
assert!(setting(PackageWorkflow::GitSubmodules, "luau-lsp.ignoreGlobs").is_some());
}
}