Skip to content
This repository was archived by the owner on Oct 9, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ tree-sitter-ada = "0.1.0"
tree-sitter-asm = "0.24.0"
tree-sitter-bash = "0.25.1"
tree-sitter-c = "0.24"
tree-sitter-c-sharp = "0.23.1"
tree-sitter-c-sharp = { version = "0.23.1", optional = true }
tree-sitter-clojure-orchard = "0.2.5"
tree-sitter-cmake = "0.7.1"
tree-sitter-containerfile = "0.8.0"
Expand Down Expand Up @@ -105,7 +105,7 @@ tree-sitter-lua = "0.5"
tree-sitter-make = "1.1.1"
tree-sitter-newick = "1.1.0"
tree-sitter-nix = "0.3.0"
tree-sitter-objc = "3.0.2"
tree-sitter-objc = { version = "3.0.2", optional = true }
tree-sitter-ocaml = { version = "0.25.0", optional = true }
tree-sitter-pascal = "0.10.0"
tree-sitter-php = "0.24.0"
Expand Down Expand Up @@ -235,7 +235,9 @@ name = "wasm"
required-features = ["wasm-plugin-tests"]

[features]
all-languages = ["lang-fortran", "lang-fsharp", "lang-verilog", "lang-ocaml", "lang-julia", "lang-haskell", "lang-vhdl", "lang-apex", "lang-qml"]
all-languages = ["lang-csharp", "lang-objc", "lang-fortran", "lang-fsharp", "lang-verilog", "lang-ocaml", "lang-julia", "lang-haskell", "lang-vhdl", "lang-apex", "lang-qml"]
lang-csharp = ["dep:tree-sitter-c-sharp"]
lang-objc = ["dep:tree-sitter-objc"]
lang-fortran = ["dep:tree-sitter-fortran"]
lang-fsharp = ["dep:tree-sitter-fsharp"]
lang-verilog = ["dep:tree-sitter-verilog"]
Expand Down
8 changes: 8 additions & 0 deletions diffr-ts/bin/binary.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export interface EnsureBinaryOptions {
into?: string;
full?: boolean;
check?: boolean;
required?: boolean;
pins?: string;
}
export function ensureBinary(options?: EnsureBinaryOptions): Promise<string | undefined>;
92 changes: 92 additions & 0 deletions diffr-ts/bin/binary.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const targets = { "darwin-arm64": "aarch64-apple-darwin", "darwin-x64": "x86_64-apple-darwin", "linux-x64": "x86_64-unknown-linux-gnu", "linux-arm64": "aarch64-unknown-linux-gnu", "win32-x64": "x86_64-pc-windows-msvc" };

const pending = new Map();
export function ensureBinary(options = {}) {
const { version } = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
const into = options.into ?? join(process.env.XDG_CACHE_HOME || join(homedir(), ".cache"), "diffr", version, `${process.platform}-${process.arch}`, options.full ? "full" : "lean");
const values = { full: false, required: true, pins: join(root, "pins.json"), ...options, into };
const key = JSON.stringify([resolve(into), values.full, values.pins, values.check, values.required]);
if (pending.has(key)) return pending.get(key);
const task = installBinary(values).finally(() => pending.delete(key));
pending.set(key, task);
return task;
}

async function installBinary(values) {
if (!values.into) throw new Error("--into <dir> is required");
const into = resolve(values.into);
const { version } = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
const pins = JSON.parse(readFileSync(values.pins, "utf8"));
if (pins.version !== version) throw new Error(`pins.json is for ${pins.version}, package is ${version}; run \`npm run pin\``);
const target = targets[`${process.platform}-${process.arch}`];
if (!target) throw new Error(`no diffr release for ${process.platform}-${process.arch}`);
const pin = values.full ? pins.full : pins;
if (!pin) throw new Error(`pins.json has no full edition for ${version}`);
const expected = pin.sha256?.[target];
if (typeof expected !== "string" || !/^[a-f0-9]{64}$/.test(expected)) {
throw new Error(`pins.json has no valid sha256 for ${target}`);
}

const name = process.platform === "win32" ? "diffr.exe" : "diffr";
const binary = join(into, name);
const stampPath = join(into, "diffr.stamp.json");
let installed = false;
try {
const stamp = JSON.parse(readFileSync(stampPath, "utf8"));
const stat = lstatSync(binary);
installed = stat.isFile() && (process.platform === "win32" || (stat.mode & 0o111) !== 0) && stamp.version === version && stamp.target === target
&& Boolean(stamp.full) === Boolean(values.full)
&& typeof stamp.binarySha256 === "string"
&& createHash("sha256").update(readFileSync(binary)).digest("hex") === stamp.binarySha256;
} catch { /* Missing or damaged installations are fetched again. */ }
if (installed) {
return binary;
}
if (values.check) throw new Error(`${binary} is missing or not diffr ${version} (${target})`);

const artifact = values.full ? "diffr-cli-full" : pins.artifact ?? "diffr";
if (!["diffr", "diffr-cli", "diffr-cli-full"].includes(artifact)) throw new Error(`unknown pinned artifact: ${artifact}`);
const asset = `${artifact}-${version}-${target}.tar.gz`;
const url = `https://github.com/devdotfast/diffr/releases/download/${version}/${asset}`;
let bytes;
try {
const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(120_000) });
if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`);
bytes = Buffer.from(await response.arrayBuffer());
} catch (error) {
if (values.required) throw new Error(`could not download ${url}: ${error.message}`);
console.warn(`diffr-fetch: could not download ${url}: ${error.message}; continuing without a bundled diffr`);
return;
}
const actual = createHash("sha256").update(bytes).digest("hex");
if (actual !== expected) throw new Error(`${asset} sha256 ${actual} does not match pinned ${expected}`);

mkdirSync(into, { recursive: true });
const staging = mkdtempSync(join(into, ".diffr-"));
try {
writeFileSync(join(staging, "archive.tar.gz"), bytes);
// Relative paths: GNU tar on Windows reads "C:" in an archive path as a remote host.
const tar = spawnSync("tar", ["-xzf", "archive.tar.gz", name], { cwd: staging, encoding: "utf8" });
const extracted = join(staging, name);
if (tar.status !== 0 || !lstatSync(extracted).isFile()) {
throw new Error(`${asset} does not contain a regular ${name} file at its root`);
}
chmodSync(extracted, 0o755);
const stamp = join(staging, "stamp.json");
writeFileSync(stamp, `${JSON.stringify({ version, target, binarySha256: createHash("sha256").update(readFileSync(extracted)).digest("hex"), ...(values.full && { full: true }) }, null, 2)}\n`);
renameSync(extracted, binary);
renameSync(stamp, stampPath);
} finally {
rmSync(staging, { recursive: true, force: true });
}
return binary;
}
93 changes: 6 additions & 87 deletions diffr-ts/bin/fetch.mjs
Original file line number Diff line number Diff line change
@@ -1,93 +1,12 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";

const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const targets = { "darwin-arm64": "aarch64-apple-darwin", "darwin-x64": "x86_64-apple-darwin", "linux-x64": "x86_64-unknown-linux-gnu", "linux-arm64": "aarch64-unknown-linux-gnu", "win32-x64": "x86_64-pc-windows-msvc" };

import { ensureBinary } from "./binary.mjs";
try {
await main();
} catch (error) {
console.error(`diffr-fetch: ${error.message}`);
process.exitCode = 1;
}

async function main() {
const { values } = parseArgs({ options: {
into: { type: "string" },
full: { type: "boolean", default: false },
check: { type: "boolean" },
required: { type: "boolean" },
pins: { type: "string", default: join(root, "pins.json") },
into: { type: "string" }, full: { type: "boolean", default: false },
check: { type: "boolean" }, required: { type: "boolean", default: false }, pins: { type: "string" },
} });
if (!values.into) throw new Error("--into <dir> is required");
const into = resolve(values.into);
const { version } = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
const pins = JSON.parse(readFileSync(values.pins, "utf8"));
if (pins.version !== version) throw new Error(`pins.json is for ${pins.version}, package is ${version}; run \`npm run pin\``);
const target = targets[`${process.platform}-${process.arch}`];
if (!target) throw new Error(`no diffr release for ${process.platform}-${process.arch}`);
const pin = values.full ? pins.full : pins;
if (!pin) throw new Error(`pins.json has no full edition for ${version}`);
const expected = pin.sha256?.[target];
if (typeof expected !== "string" || !/^[a-f0-9]{64}$/.test(expected)) {
throw new Error(`pins.json has no valid sha256 for ${target}`);
}

const name = process.platform === "win32" ? "diffr.exe" : "diffr";
const binary = join(into, name);
const stampPath = join(into, "diffr.stamp.json");
let installed = false;
try {
const stamp = JSON.parse(readFileSync(stampPath, "utf8"));
const stat = lstatSync(binary);
installed = stat.isFile() && (process.platform === "win32" || (stat.mode & 0o111) !== 0) && stamp.version === version && stamp.target === target
&& Boolean(stamp.full) === values.full;
} catch { /* Missing or damaged installations are fetched again. */ }
if (installed) {
console.log(`diffr ${version} (${target}) already at ${binary}`);
return;
}
if (values.check) throw new Error(`${binary} is missing or not diffr ${version} (${target})`);

const artifact = values.full ? "diffr-cli-full" : pins.artifact ?? "diffr";
if (!["diffr", "diffr-cli", "diffr-cli-full"].includes(artifact)) throw new Error(`unknown pinned artifact: ${artifact}`);
const asset = `${artifact}-${version}-${target}.tar.gz`;
const url = `https://github.com/devdotfast/diffr/releases/download/${version}/${asset}`;
let bytes;
try {
const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(120_000) });
if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`);
bytes = Buffer.from(await response.arrayBuffer());
} catch (error) {
if (values.required) throw new Error(`could not download ${url}: ${error.message}`);
console.warn(`diffr-fetch: could not download ${url}: ${error.message}; continuing without a bundled diffr`);
return;
}
const actual = createHash("sha256").update(bytes).digest("hex");
if (actual !== expected) throw new Error(`${asset} sha256 ${actual} does not match pinned ${expected}`);

mkdirSync(into, { recursive: true });
const staging = mkdtempSync(join(into, ".diffr-"));
try {
writeFileSync(join(staging, "archive.tar.gz"), bytes);
// Relative paths: GNU tar on Windows reads "C:" in an archive path as a remote host.
const tar = spawnSync("tar", ["-xzf", "archive.tar.gz", name], { cwd: staging, encoding: "utf8" });
const extracted = join(staging, name);
if (tar.status !== 0 || !lstatSync(extracted).isFile()) {
throw new Error(`${asset} does not contain a regular ${name} file at its root`);
}
chmodSync(extracted, 0o755);
const stamp = join(staging, "stamp.json");
writeFileSync(stamp, `${JSON.stringify({ version, target, ...(values.full && { full: true }) }, null, 2)}\n`);
renameSync(extracted, binary);
renameSync(stamp, stampPath);
} finally {
rmSync(staging, { recursive: true, force: true });
}
console.log(`diffr ${version} (${target}) installed at ${binary}`);
}
const binary = await ensureBinary(Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined)));
if (binary) console.log(`diffr installed at ${binary}`);
} catch (error) { console.error(`diffr-fetch: ${error.message}`); process.exitCode = 1; }
3 changes: 2 additions & 1 deletion diffr-ts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"default": "./dist/index.js"
},
"./package.json": "./package.json",
"./src/*": "./src/*"
"./src/*": "./src/*",
"./binary": "./bin/binary.mjs"
},
"bin": {
"diffr-fetch": "bin/fetch.mjs"
Expand Down
15 changes: 13 additions & 2 deletions diffr-ts/src/fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ test("downloads, verifies, extracts, stamps, and reuses the pinned binary", () =
);
expect(readFileSync(join(f.into, "diffr"), "utf8")).toBe(f.content);
expect(statSync(join(f.into, "diffr")).mode & 0o777).toBe(0o755);
expect(JSON.parse(readFileSync(join(f.into, "diffr.stamp.json"), "utf8"))).toEqual({ version, target });
expect(JSON.parse(readFileSync(join(f.into, "diffr.stamp.json"), "utf8"))).toMatchObject({ version, target });
expect(readdirSync(f.into).sort()).toEqual(["diffr", "diffr.stamp.json"]);
rmSync(join(f.dir, "requested"));
expect(f.run(["--check"], true).code).toBe(0);
Expand Down Expand Up @@ -131,7 +131,7 @@ test("Linux ARM64 downloads and stamps the aarch64 release", () => {
`https://github.com/devdotfast/diffr/releases/download/${version}/diffr-cli-${version}-aarch64-unknown-linux-gnu.tar.gz`,
);
expect(readFileSync(join(f.into, "diffr"), "utf8")).toBe(f.content);
expect(JSON.parse(readFileSync(join(f.into, "diffr.stamp.json"), "utf8"))).toEqual({
expect(JSON.parse(readFileSync(join(f.into, "diffr.stamp.json"), "utf8"))).toMatchObject({
version, target: "aarch64-unknown-linux-gnu",
});
expect(f.run(["--check"], true).code).toBe(0);
Expand Down Expand Up @@ -178,3 +178,14 @@ test("--full downloads the full archive, fails without full pins, and refetches
expect(f.run(["--full", "--check"]).code).toBe(0);
expect(f.run(["--check"]).code).toBe(1);
});


test("corrupt executable is rejected offline and repaired on retry", () => {
const f = fixture();
expect(f.run().code).toBe(0);
writeFileSync(join(f.into, "diffr"), "corrupt");
expect(f.run(["--check"], true).code).toBe(1);
expect(f.run(["--required"], true).code).toBe(1);
expect(f.run().code).toBe(0);
expect(readFileSync(join(f.into, "diffr"), "utf8")).toBe(f.content);
});
10 changes: 10 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ const METADATA: [&str; 5] = ["name_only", "name_status", "stat", "numstat", "sho
struct Cli {
#[command(subcommand)]
command: Option<Command>,
/// Print machine-readable optional-language capabilities
#[arg(long)]
capabilities: bool,
/// Directory in the repository to diff from; paths are relative to it
#[arg(long, default_value = ".")]
repo: PathBuf,
Expand Down Expand Up @@ -176,6 +179,13 @@ pub(crate) fn run() -> Result<i32> {
let matches = Cli::command().get_matches();
reject_diff_arguments_before_subcommand(&matches);
let args = Cli::from_arg_matches(&matches).unwrap_or_else(|error| error.exit());
if args.capabilities {
println!(
"{}",
serde_json::json!({ "version": env!("CARGO_PKG_VERSION"), "optionalLanguages": crate::parse::guess_language::optional_languages() })
);
return Ok(0);
}
match &args.command {
Some(Command::Config(config)) => return run_config(config),
Some(Command::Debug(debug)) => {
Expand Down
28 changes: 26 additions & 2 deletions src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,35 @@ pub(crate) fn diff_file_content(

let language = guess(Path::new(display_path), guess_src, overrides);
let lang_config = language.map(|lang| (lang, params.language(lang)));
let plain_format = || {
let explicitly_overridden = overrides.iter().any(|(_, globs)| {
globs.iter().any(|glob| {
Path::new(display_path)
.file_name()
.is_some_and(|name| glob.matches(&name.to_string_lossy()))
})
});
let missing = (!explicitly_overridden)
.then(|| {
crate::parse::guess_language::missing_optional_language(
Path::new(display_path),
guess_src,
)
})
.flatten();
match missing {
Some(name) => FileFormat::TextFallback {
cause: FallbackCause::MissingExtraLanguage,
reason: name,
},
None => FileFormat::PlainText,
}
};

if lhs_src == rhs_src {
let file_format = match language {
Some(language) => FileFormat::SupportedLanguage(language),
None => FileFormat::PlainText,
None => plain_format(),
};

// If the two files are byte-for-byte identical, return early
Expand Down Expand Up @@ -141,7 +165,7 @@ pub(crate) fn diff_file_content(
(file_format, lhs_positions, rhs_positions)
}
None => {
let file_format = FileFormat::PlainText;
let file_format = plain_format();
let (lhs_positions, rhs_positions) = line_parser::change_positions(lhs_src, rhs_src);
(file_format, lhs_positions, rhs_positions)
}
Expand Down
Loading
Loading