diff --git a/Cargo.toml b/Cargo.toml index 67119a03f..a83903a16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" @@ -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" @@ -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"] diff --git a/diffr-ts/bin/binary.d.mts b/diffr-ts/bin/binary.d.mts new file mode 100644 index 000000000..de42e1c81 --- /dev/null +++ b/diffr-ts/bin/binary.d.mts @@ -0,0 +1,8 @@ +export interface EnsureBinaryOptions { + into?: string; + full?: boolean; + check?: boolean; + required?: boolean; + pins?: string; +} +export function ensureBinary(options?: EnsureBinaryOptions): Promise; diff --git a/diffr-ts/bin/binary.mjs b/diffr-ts/bin/binary.mjs new file mode 100644 index 000000000..175d42faa --- /dev/null +++ b/diffr-ts/bin/binary.mjs @@ -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 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; +} diff --git a/diffr-ts/bin/fetch.mjs b/diffr-ts/bin/fetch.mjs index 2910e1bd1..4b0e11c54 100755 --- a/diffr-ts/bin/fetch.mjs +++ b/diffr-ts/bin/fetch.mjs @@ -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 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; } diff --git a/diffr-ts/package.json b/diffr-ts/package.json index a0ac7163d..870fe334e 100644 --- a/diffr-ts/package.json +++ b/diffr-ts/package.json @@ -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" diff --git a/diffr-ts/src/fetch.test.ts b/diffr-ts/src/fetch.test.ts index 63eeb8c7a..a450a1af4 100644 --- a/diffr-ts/src/fetch.test.ts +++ b/diffr-ts/src/fetch.test.ts @@ -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); @@ -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); @@ -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); +}); diff --git a/src/cli.rs b/src/cli.rs index 65128fb7e..eb391248e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -32,6 +32,9 @@ const METADATA: [&str; 5] = ["name_only", "name_status", "stat", "numstat", "sho struct Cli { #[command(subcommand)] command: Option, + /// 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, @@ -176,6 +179,13 @@ pub(crate) fn run() -> Result { 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)) => { diff --git a/src/engine.rs b/src/engine.rs index ef4d8eb02..f7ecac291 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -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 @@ -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) } diff --git a/src/parse/guess_language.rs b/src/parse/guess_language.rs index f68550d6e..8b7764e9e 100644 --- a/src/parse/guess_language.rs +++ b/src/parse/guess_language.rs @@ -29,6 +29,7 @@ pub(crate) enum Language { CMake, CommonLisp, CPlusPlus, + #[cfg(feature = "lang-csharp")] CSharp, Css, Dart, @@ -62,6 +63,7 @@ pub(crate) enum Language { Make, Newick, Nix, + #[cfg(feature = "lang-objc")] ObjC, #[cfg(feature = "lang-ocaml")] OCaml, @@ -140,6 +142,7 @@ pub(crate) fn language_name(language: Language) -> &'static str { CMake => "CMake", CommonLisp => "Common Lisp", CPlusPlus => "C++", + #[cfg(feature = "lang-csharp")] CSharp => "C#", Css => "CSS", Dart => "Dart", @@ -173,6 +176,7 @@ pub(crate) fn language_name(language: Language) -> &'static str { Make => "Make", Newick => "Newick", Nix => "Nix", + #[cfg(feature = "lang-objc")] ObjC => "Objective-C", #[cfg(feature = "lang-ocaml")] OCaml => "OCaml", @@ -282,6 +286,7 @@ pub(crate) fn language_globs(language: Language) -> Vec { "*.cc", "*.cpp", "*.c++", "*.cxx", "*.cu", "*.h", "*.hh", "*.hpp", "*.hxx", "*.inl", "*.ino", "*.ipp", "*.ixx", "*.tcc", ], + #[cfg(feature = "lang-csharp")] CSharp => &["*.cs"], Css => &["*.css"], Dart => &["*.dart"], @@ -382,6 +387,7 @@ pub(crate) fn language_globs(language: Language) -> Vec { ], Newick => &["*.nhx", "*.nwk", "*.nh"], Nix => &["*.nix"], + #[cfg(feature = "lang-objc")] ObjC => &["*.m"], #[cfg(feature = "lang-ocaml")] OCaml => &["*.ml"], @@ -531,7 +537,10 @@ pub(crate) fn guess( return None; } if looks_like_objc(path, src) { + #[cfg(feature = "lang-objc")] return Some(Language::ObjC); + #[cfg(not(feature = "lang-objc"))] + return None; } if let Some(lang) = from_glob(path) { @@ -567,6 +576,7 @@ fn from_emacs_mode_header(src: &str) -> Option { "ada" => Ada, "c" => C, "clojure" => Clojure, + #[cfg(feature = "lang-csharp")] "csharp" => CSharp, "css" => Css, "dart" => Dart, @@ -590,6 +600,7 @@ fn from_emacs_mode_header(src: &str) -> Option { "js" | "js2" => JavaScript, "lisp" => CommonLisp, "nxml" => Xml, + #[cfg(feature = "lang-objc")] "objc" => ObjC, "perl" => Perl, "python" => Python, @@ -794,3 +805,38 @@ mod tests { ); } } + +pub(crate) fn optional_languages() -> serde_json::Value { + serde_json::json!([ + { "name": "C#", "extensions": ["cs"], "available": cfg!(feature = "lang-csharp") }, + { "name": "Objective-C", "extensions": ["m"], "available": cfg!(feature = "lang-objc") }, + { "name": "Fortran", "extensions": ["f", "f90", "for"], "available": cfg!(feature = "lang-fortran") }, + { "name": "F#", "extensions": ["fs", "fsx", "fsi"], "available": cfg!(feature = "lang-fsharp") }, + { "name": "Verilog", "extensions": ["v", "sv", "vh"], "available": cfg!(feature = "lang-verilog") }, + { "name": "OCaml", "extensions": ["ml", "mli"], "available": cfg!(feature = "lang-ocaml") }, + { "name": "Julia", "extensions": ["jl"], "available": cfg!(feature = "lang-julia") }, + { "name": "Haskell", "extensions": ["hs"], "available": cfg!(feature = "lang-haskell") }, + { "name": "VHDL", "extensions": ["vhd", "vhdl"], "available": cfg!(feature = "lang-vhdl") }, + { "name": "Apex", "extensions": ["apexc", "trigger"], "available": cfg!(feature = "lang-apex") }, + { "name": "QML", "extensions": ["qml"], "available": cfg!(feature = "lang-qml") } + ]) +} + +pub(crate) fn missing_optional_language(path: &Path, source: &str) -> Option { + if !cfg!(feature = "lang-objc") && looks_like_objc(path, source) { + return Some("Objective-C".to_owned()); + } + let extension = path.extension()?.to_str()?.to_ascii_lowercase(); + optional_languages() + .as_array()? + .iter() + .find_map(|language| { + (!language["available"].as_bool().unwrap_or(false) + && language["extensions"] + .as_array() + .unwrap() + .iter() + .any(|value| value.as_str() == Some(&extension))) + .then(|| language["name"].as_str().unwrap().to_owned()) + }) +} diff --git a/src/parse/tree_sitter_parser.rs b/src/parse/tree_sitter_parser.rs index 6f4cdef9e..21fa6fa72 100644 --- a/src/parse/tree_sitter_parser.rs +++ b/src/parse/tree_sitter_parser.rs @@ -277,6 +277,7 @@ fn build_config(language: guess::Language) -> TreeSitterConfig { sub_languages: vec![], } } + #[cfg(feature = "lang-csharp")] CSharp => { let language_fn = tree_sitter_c_sharp::LANGUAGE; let language = tree_sitter::Language::new(language_fn); @@ -827,6 +828,7 @@ fn build_config(language: guess::Language) -> TreeSitterConfig { sub_languages: vec![], } } + #[cfg(feature = "lang-objc")] ObjC => { let language_fn = tree_sitter_objc::LANGUAGE; let language = tree_sitter::Language::new(language_fn); diff --git a/src/protocol/project.rs b/src/protocol/project.rs index 9ae85a034..6af7ce91a 100644 --- a/src/protocol/project.rs +++ b/src/protocol/project.rs @@ -129,6 +129,7 @@ fn stats(result: &DiffResult, lhs_src: &str, rhs_src: &str) -> Stats { /// is the engine's own prose, with the numbers. fn fallback_code(cause: FallbackCause) -> &'static str { match cause { + FallbackCause::MissingExtraLanguage => "missing_extra_language", FallbackCause::Generated => "generated", FallbackCause::ByteLimit => "too_large", FallbackCause::GraphLimit => "too_complex", @@ -1276,6 +1277,46 @@ mod tests { ); } + #[test] + fn optional_grammars_keep_readable_sources_and_report_recoverable_fallbacks() { + for (path, lhs, rhs, available) in [ + ( + "a.cs", + "class A { int x = 1; }\n", + "class A { int x = 2; }\n", + cfg!(feature = "lang-csharp"), + ), + ( + "a.m", + "@interface A\n@end\n", + "@interface B\n@end\n", + cfg!(feature = "lang-objc"), + ), + ( + "a.h", + "@interface A\n@end\n", + "@interface B\n@end\n", + cfg!(feature = "lang-objc"), + ), + ] { + let diff = project(path, lhs, rhs); + let Diff::Text { stats, .. } = &diff else { + panic!("text") + }; + let (left, right) = sources(&diff); + assert_eq!(left.unwrap().text, lhs); + assert_eq!(right.unwrap().text, rhs); + if available { + assert!(stats.fallback.is_none()); + } else { + assert_eq!( + stats.fallback.as_ref().unwrap().code, + "missing_extra_language" + ); + } + } + } + #[test] fn one_sided_files_have_one_source_and_no_shared_ids() { let diff = project("a.py", "", "def f():\n return 1\n"); diff --git a/src/summary.rs b/src/summary.rs index c961536f1..6cf3cba2b 100644 --- a/src/summary.rs +++ b/src/summary.rs @@ -28,6 +28,7 @@ pub(crate) enum FileFormat { /// Why a file in a supported language was diffed by line. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum FallbackCause { + MissingExtraLanguage, /// A side is larger than the byte limit. ByteLimit, /// The AST matching graph grew past the graph limit.