From b1180d7361b83779b6868fbe83b3f0f54a9b8dc7 Mon Sep 17 00:00:00 2001 From: Joe Date: Wed, 18 Feb 2026 23:27:53 -0600 Subject: [PATCH] Enhance Luau documentation with additional context and examples - Updated the Luau rules documentation to clarify the default context as Roblox Luau and provided detailed paths for accessing Roblox-specific and official Luau documentation. - Added new sections on string literal escapes, number literals, primitive types, and type refinements, including examples to illustrate usage. - Improved explanations of sealed vs unsealed tables and user-defined type functions, enhancing clarity for developers. These changes aim to improve the comprehensiveness and usability of the Luau documentation, facilitating better understanding and application of the language features. --- .cursor/rules/luau.mdc | 410 ++++++++++++++++++++++++++++++++++++++--- src/git.rs | 285 ++++++++++++++++++++++++++-- 2 files changed, 656 insertions(+), 39 deletions(-) diff --git a/.cursor/rules/luau.mdc b/.cursor/rules/luau.mdc index d860a62a..e6712788 100644 --- a/.cursor/rules/luau.mdc +++ b/.cursor/rules/luau.mdc @@ -3,9 +3,13 @@ alwaysApply: true --- # Luau Language Rules +**Default context: Roblox Luau.** Unless otherwise stated, assume we are programming in Roblox Studio. Standard Luau differences are noted where relevant. + ## Finding Documentation -Search `.cursor/rules/roblox/luau/` for detailed Luau documentation files. +- `.cursor/rules/roblox/en-us/luau/` - Roblox-specific Luau documentation (types, Roblox classes/enums, Studio integration) +- `.cursor/rules/luau/` - Official Luau language documentation (type system, standard library, syntax, guides) +- `.cursor/rules/luau-rfcs/` - RFC documents detailing every language feature and design decision ## Single-Threaded Execution Model @@ -83,6 +87,19 @@ print(`Result: {1 + 2}`) -- Result: 3 print(`Literal braces: \{not interpolated}`) ``` +### String Literal Escapes + +Beyond standard escapes, Luau supports: + +```luau +"\xAB" -- Hex escape: inserts byte 0xAB +"\u{1F600}" -- Unicode escape: inserts UTF-8 for U+1F600 (braces required) +"long\z + string" -- \z skips following whitespace/newlines: "longstring" +``` + +Backtick strings (interpolated) use `\` to escape `` ` ``, `{`, `\`, and newlines. `{{` is a parse error (use `\{` to get a literal brace). + ### If Expressions (Ternary) ```luau @@ -113,6 +130,18 @@ str ..= "suffix" local half = width // 2 -- More efficient than math.floor(width / 2) ``` +### Number Literals + +Luau has a single number type (64-bit IEEE 754 double, integers exact up to 2^53). + +```luau +local hex = 0xFF -- Hexadecimal +local bin = 0b10101010 -- Binary +local big = 1_048_576 -- Decimal separators for readability +local hexSep = 0xFFFF_FFFF +local binSep = 0b_0101_0101 +``` + ## Type System Essentials ### Type Modes @@ -121,32 +150,70 @@ local half = width // 2 -- More efficient than math.floor(width / 2) - `--!nonstrict`: Reduced checking - `--!nocheck`: Disabled -### Core Types +### Primitive Types + +The Luau VM has 10 primitive types: `nil`, `string`, `number`, `boolean`, `table`, `function`, `thread`, `userdata`, `vector`, `buffer`. ```luau --- Primitives local x: number local s: string local b: boolean -local arr: {string} -- Array -local map: {[string]: number} -- Dictionary +local co: thread = coroutine.running() +local buf: buffer = buffer.create(256) +``` --- Optional -local maybe: string? -- string | nil +Note: `table` and `function` use dedicated syntax (not written by name). `vector` is not representable by name in type annotations. `userdata` is represented by concrete host types. --- Union / Intersection +### `unknown`, `any`, and `never` + +```luau +local u: unknown = anything -- Top type: accepts all values, but cannot be used without refinement +local a: any = anything -- Escape hatch: opts out of type checking entirely +local n: never -- Bottom type: no value inhabits this type +``` + +- `unknown` requires type refinement (e.g., `typeof(x) == "string"`) before use +- `any` silently allows all operations -- use sparingly +- `never` is useful for exhaustiveness checking and impossible branches + +### Core Type Syntax + +```luau +local arr: {string} -- Array shorthand (equivalent to {[number]: string}) +local map: {[string]: number} -- Dictionary with indexer +local obj: { x: number, y: number } -- Table with named properties + +local maybe: string? -- Optional: string | nil + +-- Union / Intersection (leading | and & are allowed for formatting) type Result = "ok" | "error" +type Action + = { type: "add", value: number } + | { type: "remove", id: string } type Combined = TypeA & TypeB --- Negation -type NotNil = ~nil +type NotNil = ~nil -- Negation type --- Singleton (literal types) +-- Singleton (literal) types -- strings and booleans only, not numbers type Direction = "up" | "down" | "left" | "right" +type Truthy = true --- Function types +-- Function types with optional named parameters type Callback = (number, string) -> boolean +type Named = (errorCode: number, errorText: string) -> () type Generic = (T) -> T +type Multi = (number, string) -> (boolean, string) -- Multiple returns +``` + +### Generics with Defaults + +Type aliases support default type parameters (functions do not): + +```luau +type Pair = { first: T, second: T } +type PairWithDefault = Pair + +local p: PairWithDefault = { first = "hello", second = "world" } -- T defaults to string ``` ### Read-Only Properties @@ -182,8 +249,10 @@ end ### Built-in Type Functions ```luau -type Keys = keyof -type PropType = index +type Keys = keyof -- Union of all property names +type RawKeys = rawkeyof -- Keys without __index +type PropType = index -- Property type lookup +type RawProp = rawget -- Property lookup without __index ``` ### Variadic Type Packs @@ -241,18 +310,164 @@ type Vector = typeof(setmetatable({}::{ })) ``` +### Type Refinements + +The type checker narrows types based on control flow: + +```luau +local x: string | number = getValue() + +-- Type guard with type() or typeof() +if type(x) == "string" then + local s: string = x -- ok, narrowed to string +end + +-- Truthy test +local maybe: string? = getMaybe() +if maybe then + local s: string = maybe -- ok, nil ruled out +end + +-- Equality narrowing to singleton +local s: string = getStr() +if s == "hello" then + local h: "hello" = s -- ok, narrowed to literal +end + +-- Compose with and/or/not +if (type(x) == "string" or type(x) == "number") and x ~= nil then + -- x: string | number +end + +-- assert() also refines +assert(type(x) == "string") +local s: string = x -- ok after assert +``` + +### Sealed vs Unsealed Tables + +Tables have different mutability rules depending on how they are created: + +**Unsealed** (table literals without annotation) -- can add new properties until scope exits: + +```luau +local t = { x = 1 } -- unsealed: {x: number} +t.y = 2 -- ok, adds y: {x: number, y: number} +t.z = 3 -- ok + +local u: { x: number, y: number? } = t -- ok, y is optional +``` + +**Sealed** (annotated or returned from function) -- locked, supports width subtyping: + +```luau +local t: { x: number } = { x = 1 } +t.y = 2 -- NOT ok, table is sealed + +type Point2D = { x: number, y: number } +type Point1D = { x: number } +local p: Point2D = { x = 5, y = 37 } +local q: Point1D = p -- ok, Point2D is a subtype (has extra properties) +``` + +Once an unsealed table leaves its defining scope (e.g., returned from a function), it becomes sealed. + +### User-Defined Type Functions + +Type functions run at analysis time and operate on types using the `types` library: + +```luau +type function simple_keyof(ty) + if not ty:is("table") then + error("Can only call keyof on tables.") + end + + local union = nil + for property in ty:properties() do + union = if union then types.unionof(union, property) else property + end + return if union then union else types.singleton(nil) +end + +type Person = { name: string, age: number } +type Keys = simple_keyof -- "age" | "name" +``` + +Available in type function environment: `types` library, `assert`, `error`, `print`, `next`, `pairs`, `ipairs`, `select`, `unpack`, `tostring`, `tonumber`, `type`, `typeof`, `math`, `table`, `string`, `bit32`, `utf8`, `buffer` libraries. + ## Roblox-Specific +For full Roblox Luau documentation, see `.cursor/rules/roblox/en-us/luau/`. + +### Roblox Types in Annotations + +All Roblox classes, data types, and enums are valid type annotations: + +```luau +local part: Part = Instance.new("Part") +local color: BrickColor = part.BrickColor +local material: Enum.Material = part.Material +local pos: Vector3 = part.Position +local cf: CFrame = part.CFrame +``` + ### Use Vector3, Not vector -The `vector` primitive exists but is poorly integrated with Roblox APIs. Always use `Vector3`: +The `vector` primitive exists but is not compatible with Roblox APIs. Always use `Vector3`: ```luau --- Correct local pos = Vector3.new(1, 2, 3) +-- NOT: vector.create(1, 2, 3) +``` --- Avoid (not compatible with most Roblox APIs) --- local pos = vector.create(1, 2, 3) +### Native Code Generation (Server Only) + +In Roblox, `--!native` compiles **server-side scripts** to machine code. There is a limit on total natively compiled code per experience. Use `@native` on individual hot functions to stay within budget. + +## Module Require Semantics + +### Roblox Luau (Default) + +In Roblox, use instance-path requires. This is the preferred style: + +```luau +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local MyModule = require(ReplicatedStorage.Modules.MyModule) +local Types = require(ReplicatedStorage.Shared.Types) + +-- Common pattern: services at top of file +local Players = game:GetService("Players") +local RunService = game:GetService("RunService") +local ServerStorage = game:GetService("ServerStorage") +``` + +### Standalone Luau (String Requires) + +Outside Roblox (e.g., Lune, standalone tooling), requires use explicit string paths with mandatory prefixes: + +```luau +local sibling = require("./sibling") -- Relative to current file +local parent = require("../parent") -- Parent directory +local lib = require("@lune/net") -- Alias (configured in .luaurc) +``` + +All paths must start with `./`, `../`, or `@` -- bare names like `require("foo")` are not allowed. Aliases are configured in `.luaurc`: + +```json +{ + "aliases": { + "lune": "packages/lune", + "shared": "src/shared" + } +} +``` + +### Cyclic Dependencies + +Require paths must be statically resolvable. To break cyclic module dependencies, cast to `any`: + +```luau +local myModule = require(MyModule) :: any ``` ## Deprecated Functions @@ -339,21 +554,43 @@ Note: `@inline` is NOT supported. Compiler handles inlining automatically. ## OOP Pattern -```luau -type Point = { x: number, y: number } +Separate the data type from the class type using `typeof(setmetatable(...))`. Annotate `self` explicitly on methods -- this is **required** for the type checker to share `self` types across methods. +```luau local Point = {} Point.__index = Point -function Point.new(): Point - return setmetatable({ x = 0, y = 0 }, Point) +type PointData = { + x: number, + y: number, +} + +export type Point = typeof(setmetatable({} :: PointData, Point)) + +function Point.new(x: number, y: number): Point + return setmetatable({ x = x, y = y }, Point) end -function Point:getX(): number - return self.x +function Point.move(self: Point, dx: number, dy: number) + self.x += dx + self.y += dy end + +function Point.magnitude(self: Point): number + return math.sqrt(self.x * self.x + self.y * self.y) +end + +local p = Point.new(3, 4) +p:move(1, 2) -- Callers still use : syntax +print(p:magnitude()) -- 5.385... ``` +Key points: +- Define methods with `.` syntax and explicit `self: ClassName` parameter +- Callers still invoke with `:` syntax +- Return type annotation on the constructor ensures compatibility with methods +- The `typeof(setmetatable(...))` trick gives full autocomplete on `self` + ## Performance Tips 1. **Preallocate tables**: `table.create(count)` for known sizes @@ -433,6 +670,52 @@ function handle(x: "a" | "b") end ``` +## Standard Library Additions + +Luau extends Lua 5.1's standard library with many functions. Below are the most important additions not covered elsewhere. + +### Math Library Extensions + +```luau +math.clamp(n, min, max) -- Clamp n to [min, max] range +math.sign(n) -- Returns -1, 0, or 1 +math.round(n) -- Round to nearest integer (halfway rounds away from 0) +math.lerp(a, b, t) -- Linear interpolation: a + (b - a) * t, exact at t=1 +math.map(x, inmin, inmax, outmin, outmax) -- Map x from input range to output range +math.noise(x, y?, z?) -- 3D Perlin noise, returns [-1, 1] +``` + +Also available: `math.log(n, base?)` (optional base), `math.random()` uses PCG RNG. + +### String Library Extensions + +```luau +-- string.split: splits string into array (default separator: ",") +local parts = string.split("a,b,c", ",") -- {"a", "b", "c"} +local chars = string.split("hello", "") -- {"h", "e", "l", "l", "o"} + +-- string.pack/unpack/packsize: binary data encoding (Lua 5.3 format strings) +-- Fixed sizes: short=16bit, long=64bit, int=32bit, size_t=32bit +local packed = string.pack("i4f", 42, 3.14) +local int, float = string.unpack("i4f", packed) +local size = string.packsize("i4f") -- 8 bytes +``` + +### bit32 Library Extensions + +```luau +bit32.countlz(n) -- Count leading zeros (returns 32 if n is 0) +bit32.countrz(n) -- Count trailing zeros (returns 32 if n is 0) +bit32.byteswap(n) -- Swap byte order (endianness) +``` + +### coroutine.close + +```luau +coroutine.close(co) -- Puts suspended/dead coroutine into dead state, frees stack +-- Returns (true) on success, (false, error) if coroutine was in error state +``` + ## Immutable Tables ```luau @@ -445,3 +728,82 @@ local config = table.freeze({ local newConfig = table.clone(config) newConfig.maxRetries = 5 ``` + +## Buffer Library + +Buffers are fixed-size mutable byte arrays. All offsets are 0-based. Reads/writes outside bounds throw errors. Uses little-endian byte order. + +```luau +local buf = buffer.create(1024) -- All bytes initialized to 0 +local buf2 = buffer.fromstring("hello") -- From string contents +local str = buffer.tostring(buf2) -- Back to string +local len = buffer.len(buf) -- Size in bytes + +-- Typed reads/writes (i=signed, u=unsigned, f=float) +buffer.writei32(buf, 0, 42) -- Write signed 32-bit int at offset 0 +local n = buffer.readi32(buf, 0) -- Read it back +buffer.writef64(buf, 4, 3.14159) -- Write 64-bit float at offset 4 +local f = buffer.readf64(buf, 4) + +-- Available: readi8/u8/i16/u16/i32/u32/f32/f64 and matching write variants + +-- String read/write +buffer.writestring(buf, 0, "hello", 5) -- Write 5 bytes from string +local s = buffer.readstring(buf, 0, 5) -- Read 5 bytes as string + +-- Bit-level access +buffer.writebits(buf, 0, 4, 0b1010) -- Write 4 bits at bit offset 0 +local bits = buffer.readbits(buf, 0, 4) -- Read 4 bits (returns unsigned int) + +-- Bulk operations +buffer.copy(target, tOff, source, sOff?, count?) -- Copy between buffers (or within same) +buffer.fill(buf, offset, value, count?) -- Fill bytes with value +``` + +## Vector Library + +The `vector` primitive is a 3-component (or 4-component in 4-wide mode) immutable value type. Components are accessed via `.x`/`.y`/`.z` (and `.w` in 4-wide mode) but cannot be written individually. + +```luau +-- Construction and constants +local v = vector.create(1, 2, 3) +local zero = vector.zero -- (0, 0, 0) +local one = vector.one -- (1, 1, 1) + +-- Arithmetic: +, -, *, /, //, unary - all work component-wise +local sum = v + vector.create(4, 5, 6) + +-- Geometric operations +vector.magnitude(v) -- Length of vector +vector.normalize(v) -- Unit vector +vector.dot(v1, v2) -- Dot product (returns number) +vector.cross(v1, v2) -- Cross product (3D only) +vector.angle(v1, v2, axis?) -- Angle in radians (axis for signed angle) +vector.lerp(v1, v2, t) -- Linear interpolation + +-- Component-wise math +vector.abs(v) -- math.abs per component +vector.sign(v) -- math.sign per component +vector.floor(v) / vector.ceil(v) -- Rounding per component +vector.clamp(v, min, max) -- Clamp per component +vector.max(v1, v2, ...) / vector.min(v1, v2, ...) +``` + +**Note:** In Roblox, prefer `Vector3` over `vector` -- `Vector3` integrates with all Roblox APIs. The `vector` library is for standalone Luau or performance-critical native code. + +## Differences from Lua + +Luau is based on Lua 5.1 with selective features from 5.2-5.4 and its own additions: + +- `_VERSION` is `"Luau"` (not `"Lua 5.1"`) +- Single number type: 64-bit double (no separate integer type) +- No tail call optimization (simplifies debugging and stack traces) +- `__eq` metamethod is always called, even when objects are rawequal +- No `goto` statement +- No bitwise operators (`<<`, `>>`, `&`, `|`, `~`) -- use `bit32` library +- No `__gc` metamethod (sandboxing constraint) +- No `io`, `os.execute`, `package`, or full `debug` library (sandboxing) +- `loadstring` cannot load bytecode, `string.dump` not available +- Table assignment order in literals follows program order (Lua 5.x may reorder) +- `function()` expressions may reuse closures when all captured upvalues are identical +- `os.time` with table argument returns UTC (not local time) diff --git a/src/git.rs b/src/git.rs index 6996144c..d1d82538 100644 --- a/src/git.rs +++ b/src/git.rs @@ -1,8 +1,10 @@ use std::{ collections::{HashMap, HashSet}, + io::{BufRead, BufReader, Write}, path::{Path, PathBuf}, process::{Command, Stdio}, sync::{Arc, Mutex}, + thread, }; use rbx_dom_weak::types::Ref; @@ -92,6 +94,7 @@ fn git_changed_files(repo_root: &Path) -> Option> { Some(changed) } +#[cfg(test)] fn git_show(repo_root: &Path, object_ref: &str) -> Option { let output = Command::new("git") .args(["-C", &repo_root.to_string_lossy(), "show", object_ref]) @@ -105,16 +108,97 @@ fn git_show(repo_root: &Path, object_ref: &str) -> Option { String::from_utf8(output.stdout).ok() } +#[cfg(test)] fn git_show_head(repo_root: &Path, rel_path: &Path) -> Option { let object_ref = format!("HEAD:{}", rel_path.to_string_lossy().replace('\\', "/")); git_show(repo_root, &object_ref) } +#[cfg(test)] fn git_show_staged(repo_root: &Path, rel_path: &Path) -> Option { let object_ref = format!(":0:{}", rel_path.to_string_lossy().replace('\\', "/")); git_show(repo_root, &object_ref) } +/// Retrieves git blob hashes for multiple object refs in a single subprocess. +/// Returns a Vec with the same length as `object_refs`, where each element is +/// `Some(sha1_hex)` if the object exists, or `None` if missing. +/// +/// Uses `git cat-file --batch-check` which outputs the stored blob hash +/// directly, avoiding the need to download and re-hash file contents. +fn git_batch_check_hashes(repo_root: &Path, object_refs: &[String]) -> Vec> { + if object_refs.is_empty() { + return Vec::new(); + } + + let mut child = match Command::new("git") + .args([ + "-C", + &repo_root.to_string_lossy(), + "cat-file", + "--batch-check", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + { + Ok(child) => child, + Err(err) => { + log::warn!("Failed to spawn git cat-file --batch-check: {}", err); + return vec![None; object_refs.len()]; + } + }; + + let stdin = child.stdin.take().unwrap(); + let refs_for_writer: Vec = object_refs.to_vec(); + let writer_thread = thread::spawn(move || { + let mut stdin = stdin; + for object_ref in &refs_for_writer { + if writeln!(stdin, "{}", object_ref).is_err() { + break; + } + } + }); + + let expected = object_refs.len(); + let mut results = Vec::with_capacity(expected); + + if let Some(stdout) = child.stdout.take() { + let reader = BufReader::new(stdout); + for line in reader.lines() { + let line = match line { + Ok(l) => l, + Err(_) => break, + }; + + if line.ends_with(" missing") { + results.push(None); + } else if let Some(sha1) = line.split(' ').next() { + if sha1.len() == 40 && sha1.bytes().all(|b| b.is_ascii_hexdigit()) { + results.push(Some(sha1.to_string())); + } else { + results.push(None); + } + } else { + results.push(None); + } + + if results.len() >= expected { + break; + } + } + } + + while results.len() < expected { + results.push(None); + } + + let _ = writer_thread.join(); + let _ = child.wait(); + results +} + pub fn compute_blob_sha1(content: &str) -> String { let header = format!("blob {}\0", content.len()); let mut hasher = Sha1::new(); @@ -158,10 +242,10 @@ struct ResolvedInstance { /// `repo_root` must be the git repository root (from `git_repo_root()`), /// cached at session start by `ServeSession`. /// -/// Uses a two-phase approach to avoid holding the tree lock during I/O: +/// Uses a three-phase approach to avoid holding the tree lock during I/O: /// 1. Run git commands to get changed files (no lock) /// 2. Briefly lock tree to resolve file paths to instance Refs and class names -/// 3. Run git show for each changed script and compute hashes (no lock) +/// 3. Single `git cat-file --batch-check` call to get blob hashes (no lock) pub fn compute_git_metadata(tree_handle: &Arc>, repo_root: &Path) -> GitMetadata { let changed_files = match git_changed_files(repo_root) { Some(files) => files, @@ -215,26 +299,51 @@ pub fn compute_git_metadata(tree_handle: &Arc>, repo_root: &Path let changed_ids: Vec = resolved.iter().map(|ri| ri.id).collect(); let mut script_committed_hashes: HashMap> = HashMap::new(); - for ri in &resolved { + struct ScriptRef { + resolved_idx: usize, + is_staged: bool, + } + + let mut object_refs = Vec::new(); + let mut script_refs: Vec = Vec::new(); + + for (idx, ri) in resolved.iter().enumerate() { if !is_script_class(&ri.class_name) { continue; } + let path_str = ri.rel_path.to_string_lossy().replace('\\', "/"); - let mut hashes = Vec::with_capacity(2); + object_refs.push(format!("HEAD:{}", path_str)); + script_refs.push(ScriptRef { + resolved_idx: idx, + is_staged: false, + }); - if let Some(head_content) = git_show_head(repo_root, &ri.rel_path) { - hashes.push(compute_blob_sha1(&head_content)); - } + object_refs.push(format!(":0:{}", path_str)); + script_refs.push(ScriptRef { + resolved_idx: idx, + is_staged: true, + }); + } - if let Some(staged_content) = git_show_staged(repo_root, &ri.rel_path) { - let staged_hash = compute_blob_sha1(&staged_content); - if hashes.is_empty() || hashes[0] != staged_hash { - hashes.push(staged_hash); + let batch_hashes = git_batch_check_hashes(repo_root, &object_refs); + + for (i, info) in script_refs.iter().enumerate() { + let sha1 = match batch_hashes.get(i).and_then(|h| h.as_ref()) { + Some(h) => h, + None => continue, + }; + let ri = &resolved[info.resolved_idx]; + let hashes = script_committed_hashes + .entry(ri.id) + .or_insert_with(|| Vec::with_capacity(2)); + + if info.is_staged { + if hashes.is_empty() || hashes[0] != *sha1 { + hashes.push(sha1.clone()); } - } - - if !hashes.is_empty() { - script_committed_hashes.insert(ri.id, hashes); + } else { + hashes.push(sha1.clone()); } } @@ -628,6 +737,152 @@ mod tests { assert_eq!(content.as_deref(), Some("init content")); } + // ----------------------------------------------------------------------- + // git_batch_check_hashes + // ----------------------------------------------------------------------- + + #[test] + fn batch_check_empty_refs() { + let dir = tempdir().unwrap(); + git_init(dir.path()); + let results = git_batch_check_hashes(dir.path(), &[]); + assert!(results.is_empty()); + } + + #[test] + fn batch_check_single_ref() { + let dir = tempdir().unwrap(); + git_init(dir.path()); + fs::write(dir.path().join("script.luau"), "local x = 42\nreturn x").unwrap(); + git_commit_all(dir.path(), "init"); + + let refs = vec!["HEAD:script.luau".to_string()]; + let results = git_batch_check_hashes(dir.path(), &refs); + assert_eq!(results.len(), 1); + + let batch_hash = results[0].as_ref().unwrap(); + let content = git_show_head(dir.path(), Path::new("script.luau")).unwrap(); + let expected_hash = compute_blob_sha1(&content); + assert_eq!(*batch_hash, expected_hash); + } + + #[test] + fn batch_check_missing_ref() { + let dir = tempdir().unwrap(); + git_init(dir.path()); + fs::write(dir.path().join("dummy.luau"), "x").unwrap(); + git_commit_all(dir.path(), "init"); + + let refs = vec!["HEAD:nonexistent.luau".to_string()]; + let results = git_batch_check_hashes(dir.path(), &refs); + assert_eq!(results.len(), 1); + assert!(results[0].is_none()); + } + + #[test] + fn batch_check_head_and_staged_same() { + let dir = tempdir().unwrap(); + git_init(dir.path()); + fs::write(dir.path().join("a.luau"), "content a").unwrap(); + git_commit_all(dir.path(), "init"); + + let refs = vec![ + "HEAD:a.luau".to_string(), + ":0:a.luau".to_string(), + ]; + let results = git_batch_check_hashes(dir.path(), &refs); + assert_eq!(results.len(), 2); + assert!(results[0].is_some()); + assert_eq!(results[0], results[1]); + assert_eq!( + results[0].as_ref().unwrap(), + &compute_blob_sha1("content a") + ); + } + + #[test] + fn batch_check_head_and_staged_differ() { + let dir = tempdir().unwrap(); + git_init(dir.path()); + fs::write(dir.path().join("b.luau"), "original").unwrap(); + git_commit_all(dir.path(), "init"); + fs::write(dir.path().join("b.luau"), "staged version").unwrap(); + git_stage(dir.path(), "b.luau"); + + let refs = vec![ + "HEAD:b.luau".to_string(), + ":0:b.luau".to_string(), + ]; + let results = git_batch_check_hashes(dir.path(), &refs); + assert_eq!(results.len(), 2); + assert!(results[0].is_some()); + assert!(results[1].is_some()); + assert_ne!(results[0], results[1]); + assert_eq!( + results[0].as_ref().unwrap(), + &compute_blob_sha1("original") + ); + assert_eq!( + results[1].as_ref().unwrap(), + &compute_blob_sha1("staged version") + ); + } + + #[test] + fn batch_check_multiple_files_mixed() { + let dir = tempdir().unwrap(); + git_init(dir.path()); + fs::write(dir.path().join("a.luau"), "content a").unwrap(); + fs::write(dir.path().join("b.luau"), "content b").unwrap(); + git_commit_all(dir.path(), "init"); + fs::write(dir.path().join("b.luau"), "content b modified").unwrap(); + git_stage(dir.path(), "b.luau"); + + let refs = vec![ + "HEAD:a.luau".to_string(), + ":0:a.luau".to_string(), + "HEAD:b.luau".to_string(), + ":0:b.luau".to_string(), + "HEAD:nonexistent.luau".to_string(), + ]; + let results = git_batch_check_hashes(dir.path(), &refs); + assert_eq!(results.len(), 5); + + assert_eq!(results[0], results[1]); // a: HEAD == staged + assert_ne!(results[2], results[3]); // b: HEAD != staged + assert!(results[4].is_none()); // missing + + assert_eq!( + results[0].as_ref().unwrap(), + &compute_blob_sha1("content a") + ); + assert_eq!( + results[2].as_ref().unwrap(), + &compute_blob_sha1("content b") + ); + assert_eq!( + results[3].as_ref().unwrap(), + &compute_blob_sha1("content b modified") + ); + } + + #[test] + fn batch_check_subdirectory_path() { + let dir = tempdir().unwrap(); + git_init(dir.path()); + fs::create_dir_all(dir.path().join("src")).unwrap(); + fs::write(dir.path().join("src/init.luau"), "init content").unwrap(); + git_commit_all(dir.path(), "init"); + + let refs = vec!["HEAD:src/init.luau".to_string()]; + let results = git_batch_check_hashes(dir.path(), &refs); + assert_eq!(results.len(), 1); + assert_eq!( + results[0].as_ref().unwrap(), + &compute_blob_sha1("init content") + ); + } + // ----------------------------------------------------------------------- // git_add // -----------------------------------------------------------------------