///|
const VERSION = "0.3.0"
///|
const SUMMARY =
#|Compare two files (or process a diff read in on stdin) using the
#|patience diff algorithm.
#|
#|If you don't supply any arguments to patdiff, it will read diff-like
#|text from stdin and color it in the normal patdiff way.
#|
#|The file ~/.patdiff is used as a config file if it exists. You can
#|write a sample config with the -make-config flag.
///|
priv enum FlagKind {
NoArg
WithArg(String)
}
///|
priv struct Flag {
name : String
aliases : Array[String]
kind : FlagKind
doc : String
}
///|
fn no_arg(name : String, doc : String) -> Flag {
{ name, aliases: [], kind: NoArg, doc, }
}
///|
fn with_arg(
name : String,
arg : String,
doc : String,
aliases~ : Array[String] = [],
) -> Flag {
{ name, aliases, kind: WithArg(arg), doc, }
}
///|
fn flags() -> Array[Flag] {
let styles = @kernel.LocationStyle::all().map(s => s.to_string()).join("|")
[
with_arg("alt-next", "NAME", "Mask next filename with NAME", aliases=[
"alt-new",
]),
with_arg("alt-prev", "NAME", "Mask prev filename with NAME", aliases=[
"alt-old",
]),
no_arg("ansi", "Output in ASCII with ANSI escape codes"),
no_arg(
"ascii", "Output in ASCII with no ANSI escape codes (implies unrefined diff)",
),
with_arg(
"context", "NUM", "Show lines of unchanged context before and after changes",
),
no_arg("default", "Use the default configuration instead of ~/.patdiff"),
no_arg(
"dont-produce-unified-lines", "Don't produce unified lines (this makes the diff unambiguous even if color is stripped)",
),
no_arg("double-check", "If files seem identical, double check with cmp"),
with_arg(
"exclude", "REGEXP", "exclude files matching this pattern when comparing two directories (overrides include patterns)",
),
with_arg(
"file", "FILE", "Use FILE as configuration file instead of ~/.patdiff",
),
no_arg(
"find-moves", "Try to find and render moves. If -dont-produce-unified-lines is set moves will not be found.",
),
with_arg(
"float-tolerance", "PERCENT", "Consider strings equal if only difference is floats changing within PERCENT",
),
no_arg(
"html", "Output in HTML format instead of default (ASCII with ANSI escape codes)",
),
with_arg(
"include", "REGEXP", "include files matching this pattern when comparing two directories",
),
no_arg("keep-whitespace", "Consider whitespace when comparing lines"),
with_arg(
"line-big-enough", "NUM", "Limit line-level semantic cleanup to the matches of length less than NUM lines",
),
with_arg(
"location-style",
"<\{styles}>",
"how to format location information in hunk headers",
),
with_arg("make-config", "FILE", "Write default configuration file"),
no_arg(
"mask-uniques", "When comparing directories, don't compare against /dev/null",
),
no_arg(
"no-interleave", "Don't attempt to split up large hunks near equalities",
),
no_arg(
"no-semantic-cleanup", "Don't do any semantic cleanup; let small, spurious matches survive",
),
no_arg("quiet", "Report only whether files differ, not the details"),
no_arg("reverse", "produce a diff that undoes the changes"),
no_arg("shallow", "When comparing directories, don't recurse into subdirs"),
with_arg("side-by-side", "", "Render a diff side by side"),
no_arg("split-long-lines", "Split long lines into multiple displayed lines"),
no_arg(
"text", "Treat all files as text (i.e. display diffs of binary file contents)",
),
no_arg("unrefined", "Don't highlight word differences between lines"),
with_arg(
"warn-if-no-trailing-newline-in-both", "BOOL", "warn when neither file ends in a newline, even though this does not constitute a diff (default: read from config, or true)",
),
with_arg(
"width", "INT", "number of columns to use for side by side diff. If not provided defaults to current terminal width.",
),
with_arg(
"word-big-enough", "NUM", "Limit word-level semantic cleanup to the matches of length less than NUM words",
),
no_arg("help", "print this help text and exit"),
no_arg("version", "print the version of this build and exit"),
]
}
///|
fn usage() -> String {
let lines = [
"File Diff using the Patience Diff algorithm\n\n patdiff [FILE1 FILE2]\n\n\{SUMMARY}\n\n=== flags ===\n",
]
for flag in flags() {
let head = match flag.kind {
NoArg => " -\{flag.name}"
WithArg(arg) => " [-\{flag.name} \{arg}]"
}
let aliases = flag.aliases.map(a => "-\{a}").join(", ")
let doc = if aliases.is_empty() {
flag.doc
} else {
"\{flag.doc} (alias: \{aliases})"
}
lines.push("\{head}\n \{doc}")
}
lines.join("\n") + "\n"
}
///|
/// A bad command line, reported the way Core's `Command` does it.
priv suberror UsageError {
UsageError(String)
}
///|
/// Resolves a flag as `Command` does: `_` and `-` are interchangeable, and any
/// unique prefix of a name will do.
fn find_flag(all : Array[Flag], arg : String) -> Flag raise UsageError {
let wanted = arg.replace_all(old="_", new="-")
let candidates = []
for flag in all {
if flag.name == wanted || flag.aliases.contains(wanted) {
return flag
}
let names = [flag.name]
names.push_iter(flag.aliases.iter())
if names.iter().any(name => name.has_prefix(wanted)) {
candidates.push(flag)
}
}
match candidates {
[flag] => flag
[] => raise UsageError("unknown flag -\{arg}")
_ => {
let names = candidates.map(flag => "-\{flag.name}").join(", ")
raise UsageError("flag -\{arg} is an ambiguous prefix: \{names}")
}
}
}
///|
priv struct ParsedArgs {
/// Value of each flag given, in order, under its canonical name.
values : Map[String, Array[String]]
anons : Array[String]
}
///|
fn parse_args(args : ArrayView[String]) -> ParsedArgs raise UsageError {
let all = flags()
let values : Map[String, Array[String]] = Map([])
let anons = []
let mut i = 0
while i < args.length() {
let arg = args[i]
i += 1
if arg == "--" {
anons.push_iter(args[i:].iter())
break
}
if arg == "-" || !arg.has_prefix("-") {
anons.push(arg)
continue
}
let name = arg.trim_start(chars="-").to_owned()
let flag = if name == "?" {
find_flag(all, "help")
} else {
find_flag(all, name)
}
match flag.kind {
NoArg => values.get_or_init(flag.name, () => []).push("")
WithArg(_) => {
if i >= args.length() {
raise UsageError("missing argument for flag -\{flag.name}")
}
values.get_or_init(flag.name, () => []).push(args[i])
i += 1
}
}
}
{ values, anons, }
}
///|
fn ParsedArgs::has(self : ParsedArgs, name : String) -> Bool {
self.values.contains(name)
}
///|
/// The value of a flag which may be given once.
fn ParsedArgs::get(
self : ParsedArgs,
name : String,
) -> String? raise UsageError {
match self.values.get(name) {
None => None
Some([value]) => Some(value)
Some(_) => raise UsageError("flag -\{name} passed more than once")
}
}
///|
fn ParsedArgs::get_int(
self : ParsedArgs,
name : String,
) -> Int32? raise UsageError {
match self.get(name) {
None => None
Some(value) =>
Some(@string.parse_int(value)) catch {
_ => raise UsageError("failed to parse -\{name} value \"\{value}\"")
}
}
}
///|
/// `Percent.of_string` from Core: a number followed by `x`, `%` or `bp`.
fn parse_percent(value : String) -> Double? {
let parse = (number : StringView, scale : Double) => {
Some(@string.parse_double(number) * scale) catch {
_ => None
}
}
if value.strip_suffix("bp") is Some(number) {
parse(number, 0.0001)
} else if value.strip_suffix("%") is Some(number) {
parse(number, 0.01)
} else if value.strip_suffix("x") is Some(number) {
parse(number, 1.0)
} else {
None
}
}
///|
/// Splits diff-like text from stdin into the two files it describes.
async fn files_from_stdin() -> (Bytes, Bytes) {
let prev = Buffer()
let next = Buffer()
let add_prefixes = [b"+", b">"]
let remove_prefixes = [b"-", b"<"]
let process = (
line : Bytes,
skip : Array[Bytes],
blank : Array[Bytes],
out : Buffer,
) => {
if !skip.iter().any(prefix => line.has_prefix(prefix)) {
match blank.search_by(prefix => line.has_prefix(prefix)) {
None => out.write_bytes(line)
Some(_) => {
out.write_byte(b' ')
out.write_bytes(line[1:])
}
}
out.write_byte(b'\n')
}
}
for line in @bstr.split_lines(@sys.read_stdin()) {
process(line, add_prefixes, remove_prefixes, prev)
process(line, remove_prefixes, add_prefixes, next)
}
let dir = @bstr.of_string(@env.get_env_var("TMPDIR").unwrap_or("/tmp"))
let dir = if dir.has_suffix(b"/") {
dir[:dir.length() - 1].to_owned()
} else {
dir
}
(
@sys.temp_file(dir, b"patdiff_prev_", b".txt", prev.contents()),
@sys.temp_file(dir, b"patdiff_next_", b".txt", next.contents()),
)
}
///|
fn output_flag(args : ParsedArgs) -> @kernel.Output? raise UsageError {
let chosen = ["html", "ascii", "ansi"].filter(name => args.has(name))
match chosen {
[] => None
["html"] => Some(Html)
["ascii"] => Some(Ascii)
["ansi"] => Some(Ansi)
_ => {
let names = chosen.map(name => "-\{name}").join(", ")
raise UsageError("Cannot pass more than one of these: \{names}")
}
}
}
///|
async fn make_config(file : Bytes) -> Int32 {
let should_write = if @sys.stat(file) is Some(_) {
@sys.print_stdout(file + b" already exists. Overwrite? (y/n) ")
@sys.flush()
let lines = @bstr.split_lines(@sys.read_stdin())
let response = @bstr.to_string(lines.get(0).unwrap_or(b"")).to_lower()
response == "yes" || response == "y"
} else {
true
}
if should_write {
@lib.save_default(file)
@sys.print_stdout(b"Default configuration written to " + file + b"\n")
0
} else {
@sys.print_stdout(b"Configuration file not written!\n")
1
}
}
///|
fn regexes(args : ParsedArgs, name : String) -> Array[@string.Regex] raise {
let out = []
for pattern in args.values.get(name).unwrap_or([]) {
out.push(@string.Regex(pattern))
}
out
}
///|
fn override_config(
config : @kernel.Configuration,
args : ParsedArgs,
output~ : @kernel.Output?,
) -> @kernel.Configuration raise {
let flag_set = (name : String, value : Bool) => {
if args.has(name) {
Some(value)
} else {
None
}
}
let no_semantic_cleanup = args.has("no-semantic-cleanup")
let big_enough = fn(name : String) raise {
let value = args.get_int(name)
if no_semantic_cleanup {
if value is Some(_) {
raise @lib.Failure(
"Cannot pass both [-\{name}] and [-no-semantic-cleanup]",
)
}
Some(1)
} else {
value
}
}
let side_by_side : @kernel.WrapOrTruncate? = match args.get("side-by-side") {
None => None
Some("wrap") => Some(Wrap)
Some("truncate") => Some(Truncate)
Some(other) =>
raise UsageError("failed to parse -side-by-side value \"\{other}\"")
}
let float_tolerance : Double?? = match args.get("float-tolerance") {
None => None
Some(value) =>
match parse_percent(value) {
Some(tolerance) => Some(Some(tolerance))
None =>
raise UsageError(
"failed to parse -float-tolerance value \"\{value}\"",
)
}
}
let location_style = match args.get("location-style") {
None => None
Some(value) =>
match @kernel.LocationStyle::of_string(value) {
Some(style) => Some(style)
None =>
raise UsageError("failed to parse -location-style value \"\{value}\"")
}
}
let warn_in_both = match args.get("warn-if-no-trailing-newline-in-both") {
None => None
Some("true") => Some(true)
Some("false") => Some(false)
Some(other) =>
raise UsageError(
"failed to parse -warn-if-no-trailing-newline-in-both value \"\{other}\"",
)
}
let produce_unified_lines = flag_set("dont-produce-unified-lines", false)
let alt = (name : String) => args.get(name).map(s => Some(@bstr.of_string(s)))
config.override_with(
output?,
unrefined?=flag_set("unrefined", true),
produce_unified_lines?,
float_tolerance?,
keep_ws?=flag_set("keep-whitespace", true),
find_moves?=if produce_unified_lines is Some(false) {
Some(false)
} else {
flag_set("find-moves", true)
},
split_long_lines?=flag_set("split-long-lines", true),
interleave?=flag_set("no-interleave", false),
assume_text?=flag_set("text", true),
context?=args.get_int("context"),
line_big_enough?=big_enough("line-big-enough"),
word_big_enough?=big_enough("word-big-enough"),
shallow?=flag_set("shallow", true),
quiet?=flag_set("quiet", true),
double_check?=flag_set("double-check", true),
mask_uniques?=flag_set("mask-uniques", true),
prev_alt?=alt("alt-prev"),
next_alt?=alt("alt-next"),
location_style?,
warn_if_no_trailing_newline_in_both?=warn_in_both,
// Unlike the other flags, these always replace what the config file says.
side_by_side~,
width_override=args.get_int("width"),
)
}
///|
async fn exists(file : Bytes) -> Bool {
@sys.stat(file) is Some(_)
}
///|
async fn is_dir(file : Bytes) -> Bool {
@sys.stat(file) is Some({ kind: Directory, .. })
}
///|
async fn compare_main(
args : ParsedArgs,
prev_file : Bytes,
next_file : Bytes,
output~ : @kernel.Output?,
) -> Int32 {
let filename = if args.has("default") {
if args.has("file") {
raise @lib.Failure("Cannot pass both [-default] and [-file]")
}
Some(b"")
} else {
args.get("file").map(s => @bstr.of_string(s))
}
let config = @lib.get_config(filename?)
let config = override_config(config, args, output~)
let shown = (file : Bytes) => @bstr.to_string(file)
let prev_exists = exists(prev_file)
let next_exists = exists(next_file)
if !prev_exists && !next_exists {
raise @lib.Failure(
"Both files, \{shown(prev_file)} and \{shown(next_file)}, do not exist",
)
}
let include_ = regexes(args, "include")
let exclude = regexes(args, "exclude")
let different = match (is_dir(prev_file), is_dir(next_file)) {
(true, false) | (false, true) => {
// One is a directory, the other is a file or missing.
let (dir, file) = if is_dir(prev_file) {
(prev_file, next_file)
} else {
(next_file, prev_file)
}
if exists(file) {
raise @lib.Failure(
"\{shown(dir)} is a directory, while \{shown(file)} is a file",
)
}
raise @lib.Failure(
"\{shown(dir)} is a directory, while \{shown(file)} does not exist",
)
}
(true, true) => {
let file_filter = if include_.is_empty() && exclude.is_empty() {
None
} else {
Some(fn(path : Bytes, stat : @sys.Stat) {
let matches = (rex : @string.Regex) => {
rex.execute(@bstr.to_string(path)) is Some(_)
}
!(stat.kind is Regular) ||
(
!exclude.iter().any(matches) &&
(include_.is_empty() || include_.iter().any(matches))
)
})
}
@lib.diff_dirs(
config,
prev_dir=prev_file,
next_dir=next_file,
file_filter~,
)
}
(false, false) => {
if !include_.is_empty() || !exclude.is_empty() {
raise @lib.Failure(
"Can only specify -include or -exclude when diffing two dirs",
)
}
@lib.diff_files(
config,
prev_file=if prev_exists { prev_file } else { b"/dev/null" },
next_file=if next_exists { next_file } else { b"/dev/null" },
)
}
}
if different {
1
} else {
0
}
}
///|
async fn run(argv : ArrayView[String]) -> Int32 {
let args = parse_args(argv)
if args.has("help") {
@sys.print_stdout(@bstr.of_string(usage()))
return 0
}
if args.has("version") {
@sys.print_stdout(@bstr.of_string(VERSION + "\n"))
return 0
}
let output = output_flag(args)
if args.get("make-config") is Some(file) {
return make_config(@bstr.of_string(file))
}
let (prev_file, next_file, temporary) = match args.anons {
[prev, next] => (@bstr.of_string(prev), @bstr.of_string(next), false)
[] => {
let (prev, next) = files_from_stdin()
(prev, next, true)
}
[_] => raise UsageError("missing anonymous argument: FILE2")
_ => raise UsageError("too many anonymous arguments")
}
let (first, second) = if args.has("reverse") {
(next_file, prev_file)
} else {
(prev_file, next_file)
}
let result = try compare_main(args, first, second, output~) catch {
err => Err(err)
} noraise {
code => Ok(code)
}
if temporary {
@sys.unlink(prev_file)
@sys.unlink(next_file)
}
match result {
Ok(code) => code
Err(err) => raise err
}
}
///|
/// An error, rendered the way OCaml's `Exn.to_string` would.
fn describe_error(err : Error) -> String {
match err {
@lib.Failure(msg) => {
let quoted = msg
.replace_all(old="\\", new="\\\\")
.replace_all(old="\"", new="\\\"")
"(Failure \"\{quoted}\")"
}
err => err.to_string()
}
}
///|
async fn main {
let argv = @env.args()
let code = run(argv[1:]) catch {
UsageError(msg) => {
@sys.print_stderr(
@bstr.of_string(
"Error parsing command line:\n\n \{msg}\n\nFor usage information, run\n\n patdiff -help\n\n",
),
)
1
}
err => {
@sys.print_stderr(
@bstr.of_string("Uncaught exception:\n \n \{describe_error(err)}\n\n"),
)
1
}
}
@sys.exit(code)
}