///|
async fn source(path : String) -> Bytes {
if path == "-" {
@stdio.stdin.read_all().binary()
} else {
@fs.read_file(path).binary()
}
}
///|
priv suberror DiffError {
DiffError(String)
}
///|
priv struct DiffSettings {
comparison : CompareOptions
brief : Bool
report_identical : Bool
text : Bool
unified : Bool
context_format : Bool
context : Int32
labels : Array[String]
recursive : Bool
new_file : Bool
unidirectional : Bool
command : String
}
///|
priv struct Comparison {
left : String
right : String
nested : Bool
left_present : Bool
right_present : Bool
left_ancestors : Array[String]
right_ancestors : Array[String]
}
///|
fn quoted(value : String) -> String {
if !value.is_empty() &&
value
.iter()
.all(char => {
(char >= 'a' && char <= 'z') ||
(char >= 'A' && char <= 'Z') ||
(char >= '0' && char <= '9') ||
"_./=-+,:".contains(char.to_string())
}) {
value
} else {
"'" + value.replace_all(old="'", new="'\\''") + "'"
}
}
///|
fn directory_operand(value : String, always~ : Bool = false) -> String {
let bytes = @bytere.argument(value)
if !always &&
!bytes.is_empty() &&
!bytes
.iter()
.any(char => char <= ' ' || char >= '\u007f' || char == '\\' || char == '"') {
return value
}
let out = StringBuilder()
out.write_char('"')
for char in bytes {
match char {
'\\' => out.write_string("\\\\")
'"' => out.write_string("\\\"")
'\n' => out.write_string("\\n")
'\t' => out.write_string("\\t")
'\r' => out.write_string("\\r")
'\u0007' => out.write_string("\\a")
'\u0008' => out.write_string("\\b")
'\u000b' => out.write_string("\\v")
'\u000c' => out.write_string("\\f")
_ => {
let code = char.to_int()
if code < 32 || code >= 127 {
out.write_char('\\')
out.write_char((48 + code / 64).unsafe_to_char())
out.write_char((48 + code / 8 % 8).unsafe_to_char())
out.write_char((48 + code % 8).unsafe_to_char())
} else {
out.write_char(char)
}
}
}
}
out.write_char('"')
out.to_string()
}
///|
fn diagnostic_path(value : String) -> String {
let bytes = @bytere.argument(value)
let mut quote = bytes.is_empty()
let mut compatible = true
let mut apostrophe = false
for i in 0..[^`|\\'".contains(char.to_string())
let unusual = char < ' ' || char >= '\u007f'
if unusual ||
special ||
(i == 0 && (char == '#' || char == '~')) ||
(bytes.length() == 1 && (char == '{' || char == '}')) {
quote = true
}
if char == '\'' {
apostrophe = true
}
if unusual ||
(special && char != ' ' && char != '\'') ||
char == '#' ||
char == '~' ||
char == '{' ||
char == '}' {
compatible = false
}
}
if !quote {
return value
}
if apostrophe && compatible {
return directory_operand(value, always=true)
}
let out = StringBuilder()
out.write_char('\'')
let mut escaped = false
for char in bytes {
let code = char.to_int()
if code < 32 || code >= 127 {
if !escaped {
out.write_string("'$'")
escaped = true
}
out.write_char('\\')
match char {
'\n' => out.write_char('n')
'\r' => out.write_char('r')
'\t' => out.write_char('t')
'\u0007' => out.write_char('a')
'\u0008' => out.write_char('b')
'\u000b' => out.write_char('v')
'\u000c' => out.write_char('f')
_ => {
out.write_char((48 + code / 64).unsafe_to_char())
out.write_char((48 + code / 8 % 8).unsafe_to_char())
out.write_char((48 + code % 8).unsafe_to_char())
}
}
} else if char == '\'' {
out.write_string("'\\''")
escaped = false
} else {
if escaped {
out.write_string("''")
escaped = false
}
out.write_char(char)
}
}
out.write_char('\'')
out.to_string()
}
///|
fn command_prefix(args : ArrayView[String]) -> String {
let out = StringBuilder()
out.write_string("diff")
let mut options = true
let mut value = false
for arg in args {
if value {
out.write_string(" " + quoted(arg))
value = false
} else if options && arg == "--" {
out.write_string(" --")
options = false
} else if options && arg != "-" && arg.has_prefix("-") {
out.write_string(" " + quoted(arg))
value = arg == "--label" ||
arg == "--unified-lines" ||
arg == "--context-lines" ||
(!arg.has_prefix("--") && (arg.has_suffix("U") || arg.has_suffix("C")))
}
}
out.to_string()
}
///|
fn separator(char : UInt16) -> Bool {
char.to_int() == 47 || (@async.platform is Windows && char.to_int() == 92)
}
///|
fn path_parts(path : String) -> (String, String) {
let mut last = -1
for i in 0.. String {
parent + (if parent.has_suffix("/") { "" } else { "/" }) + name
}
///|
async fn file_kind(path : String) -> @fs.FileKind? {
if path == "-" {
return Some(Regular)
}
try @fs.kind(path) catch {
@os_error.OSError(_) as err if err.is_ENOENT() => None
err => raise err
} noraise {
kind => Some(kind)
}
}
///|
fn kind_name(kind : @fs.FileKind) -> String {
match kind {
Directory => "a directory"
Regular => "a regular file"
Pipe => "a fifo"
Socket => "a socket"
CharDevice => "a character special file"
BlockDevice => "a block special file"
SymLink => "a symbolic link"
Unknown => "an unknown file type"
}
}
///|
async fn compare_files(
task : Comparison,
settings : DiffSettings,
missing_left : Bool,
missing_right : Bool,
) -> Int32 {
let display_left = settings.labels
.get(0)
.unwrap_or_else(() => diagnostic_path(task.left))
let display_right = settings.labels
.get(1)
.unwrap_or_else(() => diagnostic_path(task.right))
if task.left == task.right && !missing_left && !missing_right {
if settings.report_identical {
@stdio.stdout.write(
"Files \{display_left} and \{display_right} are identical\n",
)
}
return 0
}
let a = if missing_left { b"" } else { source(task.left) }
let b = if missing_right { b"" } else { source(task.right) }
let binary = !settings.text &&
(a.iter().any(v => v == b'\x00') || b.iter().any(v => v == b'\x00'))
let changes = if a == b || binary {
[]
} else {
edits(
lines(a),
lines(b),
options=settings.comparison,
context=if settings.unified || settings.context_format {
settings.context
} else {
0
},
)
}
let differs = if binary {
a != b
} else {
changes.iter().any(edit => meaningful(edit, settings.comparison))
}
if !differs {
if settings.report_identical {
@stdio.stdout.write(
"Files \{display_left} and \{display_right} are identical\n",
)
}
return 0
}
if settings.brief || binary {
let word = if settings.brief { "Files" } else { "Binary files" }
@stdio.stdout.write("\{word} \{display_left} and \{display_right} differ\n")
return 1
}
if task.nested {
@stdio.stdout.write(
settings.command +
" " +
directory_operand(task.left) +
" " +
directory_operand(task.right) +
"\n",
)
}
if settings.unified || settings.context_format {
let left = settings.labels.get(0).unwrap_or_else(() => "")
let right = settings.labels.get(1).unwrap_or_else(() => "")
let left = if settings.labels.length() > 0 {
left
} else {
header(task.left, settings.context_format, missing=missing_left)
}
let right = if settings.labels.length() > 1 {
right
} else {
header(task.right, settings.context_format, missing=missing_right)
}
@stdio.stdout.write(
unified(
changes,
settings.context,
@bytere.argument(left),
@bytere.argument(right),
context_format=settings.context_format,
options=settings.comparison,
),
)
} else {
@stdio.stdout.write(normal(changes, options=settings.comparison))
}
1
}
///|
async fn report_types(
task : Comparison,
settings : DiffSettings,
left : @fs.FileKind,
right : @fs.FileKind,
) -> Int32 {
let display_left = settings.labels
.get(0)
.unwrap_or_else(() => diagnostic_path(task.left))
let display_right = settings.labels
.get(1)
.unwrap_or_else(() => diagnostic_path(task.right))
@stdio.stdout.write(
"File \{display_left} is \{kind_name(left)} while file \{display_right} is \{kind_name(right)}\n",
)
1
}
///|
async fn compare_path(
task : Comparison,
settings : DiffSettings,
pending : Array[Comparison],
) -> Int32 {
let left = if task.left_present { file_kind(task.left) } else { None }
let right = if task.right_present { file_kind(task.right) } else { None }
let allow_left = settings.new_file || settings.unidirectional
let allow_right = settings.new_file
if left is None && right is None {
if task.left_present {
@stdio.stderr.write(
"diff: " + diagnostic_path(task.left) + ": No such file or directory\n",
)
}
if task.right_present {
@stdio.stderr.write(
"diff: " + diagnostic_path(task.right) + ": No such file or directory\n",
)
}
return 2
}
if (left is None && !allow_left) || (right is None && !allow_right) {
if task.nested && (!task.left_present || !task.right_present) {
let (parent, name) = path_parts(
if !task.left_present {
task.right
} else {
task.left
},
)
@stdio.stdout.write(
"Only in " +
diagnostic_path(parent) +
": " +
diagnostic_path(name) +
"\n",
)
return 1
}
let path = if left is None { task.left } else { task.right }
raise DiffError(diagnostic_path(path) + ": No such file or directory")
}
let left_kind = left.unwrap_or(
if right == Some(Directory) {
Directory
} else {
Regular
},
)
let right_kind = right.unwrap_or(
if left == Some(Directory) {
Directory
} else {
Regular
},
)
if task.nested && left_kind != right_kind {
return report_types(task, settings, left_kind, right_kind)
}
if left_kind != Directory {
if task.nested {
match left_kind {
Pipe | Socket | Unknown =>
return report_types(task, settings, left_kind, right_kind)
CharDevice | BlockDevice => {
if @fs.realpath(task.left) == @fs.realpath(task.right) {
return 0
}
raise DiffError(
"unsupported capability: directory device-number comparison",
)
}
_ => ()
}
}
return compare_files(task, settings, left is None, right is None)
}
if task.nested && !settings.recursive {
@stdio.stdout.write(
"Common subdirectories: " +
diagnostic_path(task.left) +
" and " +
diagnostic_path(task.right) +
"\n",
)
return 0
}
let left_ancestors = task.left_ancestors.copy()
let right_ancestors = task.right_ancestors.copy()
if left is Some(_) {
let real = @fs.realpath(task.left)
if left_ancestors.contains(real) {
raise DiffError(diagnostic_path(task.left) + ": recursive directory loop")
}
left_ancestors.push(real)
}
if right is Some(_) {
let real = @fs.realpath(task.right)
if right_ancestors.contains(real) {
raise DiffError(
diagnostic_path(task.right) + ": recursive directory loop",
)
}
right_ancestors.push(real)
}
let names : Map[String, Int32] = Map([])
if left is Some(_) {
for name in @fs.readdir(task.left) {
names[name] = 1
}
}
if right is Some(_) {
for name in @fs.readdir(task.right) {
names[name] = names.get(name).unwrap_or(0) | 2
}
}
let ordered : Array[(String, Bytes)] = names
.keys()
.map(name => (name, @bytere.bytes(@bytere.argument(name))))
.collect()
ordered.sort_by((a, b) => a.1.lexical_compare(b.1))
for i = ordered.length() - 1; i >= 0; i = i - 1 {
let name = ordered[i].0
let sides = names[name]
pending.push({
left: join_path(task.left, name),
right: join_path(task.right, name),
nested: true,
left_present: (sides & 1) != 0,
right_present: (sides & 2) != 0,
left_ancestors,
right_ancestors,
})
}
0
}
///|
async fn run(args : ArrayView[String]) -> Int32 {
let normalized : Array[String] = []
let mut options = true
for arg in args {
if arg == "--" {
options = false
}
if options && arg.has_prefix("--unified=") {
normalized.push("-U" + arg[10:].to_owned())
} else if options && arg.has_prefix("--context=") {
normalized.push("-C" + arg[10:].to_owned())
} else {
normalized.push(arg)
}
}
let parsed = @cli.parse(normalized, [
@cli.flag("brief", short='q'),
@cli.flag("report-identical-files", short='s'),
@cli.flag("text", short='a'),
@cli.flag("unified", short='u'),
@cli.option("unified-lines", short='U'),
@cli.flag("context", short='c'),
@cli.option("context-lines", short='C'),
@cli.option("label"),
@cli.flag("ignore-case", short='i'),
@cli.flag("ignore-all-space", short='w'),
@cli.flag("ignore-space-change", short='b'),
@cli.flag("ignore-blank-lines", short='B'),
@cli.flag("ignore-trailing-space", short='Z'),
@cli.flag("strip-trailing-cr"),
@cli.flag("recursive", short='r'),
@cli.flag("new-file", short='N'),
@cli.flag("unidirectional-new-file"),
@cli.flag("help"),
@cli.flag("version"),
])
if parsed.contains("help") {
@stdio.stdout.write(
"Usage: diff [-qsauciwbBZrN] [-U LINES | -C LINES] [--label LABEL] FILE1 FILE2\n --help display this help and exit\n --version display version and exit\nCompare files, directories or one stdin operand. Normal, unified and context output.\n-q reports differences only; -s reports equality; -a treats binary input as text.\n-i ignores ASCII case; -w ignores whitespace; -b ignores changes in whitespace.\n-Z ignores trailing whitespace; -B ignores blank-only change groups.\n--strip-trailing-cr removes one CR before each LF on input, including displayed lines.\n-r recurses; -N treats missing files as empty; --unidirectional-new-file applies only to the left side.\nTimestamp headers use TZ=UTC or public TZif files; label stdin explicitly.\nNon-C locale collation and link-target-text comparison remain unsupported.\n",
)
return 0
}
if parsed.contains("version") {
@stdio.stdout.write("diff 0.2.1\n")
return 0
}
guard parsed.operands.length() == 2 else {
fail("expected two file operands")
}
let names = parsed.operands
let unified_mode = parsed.contains("unified") ||
parsed.contains("unified-lines")
let context_mode = parsed.contains("context") ||
parsed.contains("context-lines")
if unified_mode && context_mode {
fail("conflicting output style options")
}
let labels = parsed.values("label")
if labels.length() > 2 {
fail("too many labels")
}
let context = @string.parse_int(
parsed
.last_value(if context_mode { "context-lines" } else { "unified-lines" })
.unwrap_or("3"),
)
guard context >= 0 && context <= 1000000 else {
fail("context must be between 0 and 1000000")
}
let settings : DiffSettings = {
comparison: compare_options(
ignore_case=parsed.contains("ignore-case"),
ignore_all_space=parsed.contains("ignore-all-space"),
ignore_space_change=parsed.contains("ignore-space-change"),
ignore_trailing_space=parsed.contains("ignore-trailing-space"),
strip_trailing_cr=parsed.contains("strip-trailing-cr"),
ignore_blank_lines=parsed.contains("ignore-blank-lines"),
),
brief: parsed.contains("brief"),
report_identical: parsed.contains("report-identical-files"),
text: parsed.contains("text"),
unified: unified_mode,
context_format: context_mode,
context,
labels,
recursive: parsed.contains("recursive"),
new_file: parsed.contains("new-file"),
unidirectional: parsed.contains("unidirectional-new-file"),
command: command_prefix(args),
}
let mut left = names[0]
let mut right = names[1]
let left_kind = file_kind(left)
let right_kind = file_kind(right)
if left_kind == Some(Directory) &&
right_kind is Some(kind) &&
kind != Directory {
if right == "-" {
raise DiffError("cannot compare '-' to a directory")
}
left = join_path(left, path_parts(right).1)
} else if right_kind == Some(Directory) &&
left_kind is Some(kind) &&
kind != Directory {
if left == "-" {
raise DiffError("cannot compare '-' to a directory")
}
right = join_path(right, path_parts(left).1)
}
let pending : Array[Comparison] = [
{
left,
right,
nested: false,
left_present: true,
right_present: true,
left_ancestors: [],
right_ancestors: [],
},
]
let mut status = 0
while pending.pop() is Some(task) {
let result = compare_path(task, settings, pending) catch {
DiffError(message) => {
@stdio.stderr.write("diff: \{message}\n")
2
}
err => {
@stdio.stderr.write("diff: \{err}\n")
2
}
}
status = status.max(result)
}
status
}
///|
fn padded(value : Int32, width : Int32) -> String {
let text = value.to_string()
"0".repeat(Int32::max(0, width - text.length())) + text
}
///|
fn context_date(date : @time.ZonedDateTime) -> String {
let weekday = match date.weekday() {
Monday => "Mon"
Tuesday => "Tue"
Wednesday => "Wed"
Thursday => "Thu"
Friday => "Fri"
Saturday => "Sat"
Sunday => "Sun"
}
let month = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov",
"Dec",
][date.month() - 1]
weekday +
" " +
month +
" " +
(if date.day() < 10 { " " } else { "" }) +
date.day().to_string() +
" " +
padded(date.hour(), 2) +
":" +
padded(date.minute(), 2) +
":" +
padded(date.second(), 2) +
" " +
date.year().to_string()
}
///|
async fn header(
path : String,
context : Bool,
missing~ : Bool = false,
) -> String {
if path == "-" {
fail("unified stdin headers require an explicit --label")
}
let zone = @time_zone.load()
let (seconds, nanos) = if missing { (0L, 0) } else { @fs.mtime(path) }
let date = zone.at_unix(seconds, nanosecond=nanos)
let name = directory_operand(path)
if context {
return name + "\t" + context_date(date)
}
let offset = date.offset().seconds()
let magnitude = if offset < 0 { -offset } else { offset }
name +
"\t" +
padded(date.year(), 4) +
"-" +
padded(date.month(), 2) +
"-" +
padded(date.day(), 2) +
" " +
padded(date.hour(), 2) +
":" +
padded(date.minute(), 2) +
":" +
padded(date.second(), 2) +
"." +
padded(nanos, 9) +
" " +
(if offset < 0 { "-" } else { "+" }) +
padded(magnitude / 3600, 2) +
padded(magnitude % 3600 / 60, 2)
}
///|
async fn main {
let status = run(@env.args()[1:]) catch {
DiffError(message) => {
@stdio.stderr.write("diff: \{message}\n")
2
}
@cli.CliError(message~, ..) => {
@stdio.stderr.write("diff: \{message}\n")
2
}
err => {
@stdio.stderr.write("diff: \{err}\n")
2
}
}
if status != 0 {
@sys.exit(status)
}
}