Files
mercury/git_hooks/update_copyright.m
Zoltan Somogyi ae701f4200 Improve pre-commit git_hook.
git_hooks/update_copyright.pre-commit:
    Document the operation of this script in more detail.

    Document the use of the new Makefile, and of the new environment
    variable that can be used to avoid redundant copies of the update_copyright
    program's executable.

git_hooks/Makefile:
    Add a trivial makefile to shorten the build and clean commands.

git_hooks/update_copyright.m:
    Merge contiguous year ranges in copyright lines.
2024-03-13 16:50:00 +11:00

418 lines
13 KiB
Mathematica

%---------------------------------------------------------------------------%
% vim: ft=mercury ts=4 sw=4 et
%---------------------------------------------------------------------------%
% Copyright (C) 2024 YesLogic Pty. Ltd.
% Copyright (C) 2024 The Mercury team.
% This file may only be copied under the terms of the GNU General
% Public License - see the file COPYING in the Mercury distribution.
%---------------------------------------------------------------------------%
%
% This program updates the first matching Copyright line of each file to
% include the current year. If one or more file names are specified on the
% command line, then those files will be updated in-place (files already
% containing up-to-date Copyright lines will not be modified).
% If no file names are specified, the input will be read from standard input,
% and the output written to standard output.
%
% Usage: update_copyright [OPTIONS] [FILES]...
%
% Options:
%
% -q, --quiet Quiet mode: don't print anything except error messages.
% -s, --suffix STR Match only Copyright lines with STR in the suffix.
%
% Exit status:
%
% 0 No files modified.
% 1 One or more files modified.
% 2 An error occurred.
%
%---------------------------------------------------------------------------%
:- module update_copyright.
:- interface.
:- import_module io.
:- pred main(io::di, io::uo) is det.
%---------------------------------------------------------------------------%
%---------------------------------------------------------------------------%
:- implementation.
:- import_module bool.
:- import_module char.
:- import_module getopt.
:- import_module int.
:- import_module list.
:- import_module maybe.
:- import_module string.
:- import_module time.
:- type year_range
---> years(int, int). % can be equal
:- type option
---> quiet
; suffix.
:- type options
---> options(bool, maybe(string)).
% The values of the quiet and suffix options respectively.
:- type mod_state
---> unmodified % no copyright line found yet
; found_unmodified % found copyright, already up-to-date
; found_modified. % found copyright, updated
%---------------------------------------------------------------------------%
main(!IO) :-
io.command_line_arguments(Args, !IO),
OptionOps = option_ops_multi(short_option, long_option, option_default),
getopt.process_options(OptionOps, Args, NonOptionArgs, OptionResult),
(
OptionResult = ok(OptionTable),
current_year(Year, !IO),
getopt.lookup_bool_option(OptionTable, quiet, Quiet),
getopt.lookup_maybe_string_option(OptionTable, suffix,
MaybeExpectSuffix),
Options = options(Quiet, MaybeExpectSuffix),
(
NonOptionArgs = [],
process_stdin(Options, Year, !IO)
;
NonOptionArgs = [_ | _],
process_files(Options, Year, NonOptionArgs, !IO)
)
;
OptionResult = error(Error),
report_error_message(option_error_to_string(Error), !IO)
).
%---------------------------------------------------------------------------%
:- pred short_option(char::in, option::out) is semidet.
short_option('q', quiet).
short_option('s', suffix).
:- pred long_option(string::in, option::out) is semidet.
long_option("quiet", quiet).
long_option("suffix", suffix).
:- pred option_default(option::out, option_data::out) is multi.
option_default(quiet, bool(no)).
option_default(suffix, maybe_string(no)).
%---------------------------------------------------------------------------%
:- pred current_year(int::out, io::di, io::uo) is det.
current_year(Year, !IO) :-
time(Time, !IO),
localtime(Time, TM, !IO),
Year = 1900 + TM ^ tm_year.
:- pred process_stdin(options::in, int::in, io::di, io::uo) is det.
process_stdin(Options, CurrentYear, !IO) :-
io.input_stream(InputStream, !IO),
read_lines_loop(InputStream, Options, CurrentYear,
[], RevLines, unmodified, _ModState, !IO),
io.output_stream(OutputStream, !IO),
list.foldl(io.write_string(OutputStream), list.reverse(RevLines), !IO).
:- pred process_files(options::in, int::in, list(string)::in,
io::di, io::uo) is det.
process_files(_Options, _CurrentYear, [], !IO).
process_files(Options, CurrentYear, [FileName | FileNames], !IO) :-
process_file(Options, CurrentYear, FileName, Continue, !IO),
(
Continue = yes,
process_files(Options, CurrentYear, FileNames, !IO)
;
Continue = no
).
:- pred process_file(options::in, int::in, string::in, bool::out,
io::di, io::uo) is det.
process_file(Options, CurrentYear, FileName, Continue, !IO) :-
io.open_input(FileName, OpenInputRes, !IO),
(
OpenInputRes = ok(InputStream),
read_lines_loop(InputStream, Options, CurrentYear,
[], RevLines, unmodified, ModState, !IO),
io.close_input(InputStream, !IO),
Options = options(Quiet, _),
(
( ModState = unmodified
; ModState = found_unmodified
),
(
Quiet = no,
io.format("Unchanged: %s\n", [s(FileName)], !IO)
;
Quiet = yes
),
Continue = yes
;
ModState = found_modified,
% It would be better to write to a temporary file and then
% atomically rename that temporary file to the original file,
% but that would require extra work to preserve the file ownership
% and permissions. For simplicity, we forgo atomicity.
io.open_output(FileName, OpenOutputRes, !IO),
(
OpenOutputRes = ok(OutputStream),
list.foldl(io.write_string(OutputStream),
list.reverse(RevLines), !IO),
io.close_output(OutputStream, !IO),
(
Quiet = no,
io.format("Modified: %s\n", [s(FileName)], !IO)
;
Quiet = yes
),
maybe_set_modified_exit_status(!IO),
Continue = yes
;
OpenOutputRes = error(Error),
report_io_error("Error opening " ++ FileName, Error, !IO),
Continue = no
)
)
;
OpenInputRes = error(Error),
report_io_error("Error opening " ++ FileName, Error, !IO),
Continue = no
).
%---------------------------------------------------------------------------%
:- pred read_lines_loop(io.text_input_stream::in, options::in, int::in,
list(string)::in, list(string)::out, mod_state::in, mod_state::out,
io::di, io::uo) is det.
read_lines_loop(InputStream, Options, CurrentYear,
!RevLines, !ModState, !IO) :-
io.read_line_as_string(InputStream, ReadRes, !IO),
(
ReadRes = ok(Line),
( if
!.ModState = unmodified,
parse_copyright_line(Options, Line, Prefix, Ranges0, Suffix)
then
list.sort(Ranges0, Ranges1),
merge_adjacent_ranges_if_possible(Ranges1, Ranges2),
( if add_to_ranges(CurrentYear, Ranges2, Ranges) then
make_copyright_line(Prefix, Ranges, Suffix, NewLine),
!:RevLines = [NewLine | !.RevLines],
!:ModState = found_modified
else
!:RevLines = [Line | !.RevLines],
!:ModState = found_unmodified
)
else
!:RevLines = [Line | !.RevLines]
),
read_lines_loop(InputStream, Options, CurrentYear,
!RevLines, !ModState, !IO)
;
ReadRes = eof
;
ReadRes = error(Error),
report_io_error("Error reading", Error, !IO)
).
:- pred parse_copyright_line(options::in, string::in,
string::out, list(year_range)::out, string::out) is semidet.
parse_copyright_line(Options, Line, Prefix, Ranges, Suffix) :-
string.sub_string_search(Line, "Copyright ", AfterCopyright),
find_prefix_end(Line, ' ', AfterCopyright, PrefixEnd),
find_suffix_start(Line, PrefixEnd, PrefixEnd, SuffixStart),
Options = options(_, MaybeExpectSuffix),
(
MaybeExpectSuffix = no
;
MaybeExpectSuffix = yes(ExpectSuffix),
string.sub_string_search_start(Line, ExpectSuffix, SuffixStart, _)
),
string.unsafe_between(Line, 0, PrefixEnd, Prefix),
string.unsafe_between(Line, PrefixEnd, SuffixStart, Mid),
string.unsafe_between(Line, SuffixStart, length(Line), Suffix),
parse_ranges(Mid, Ranges).
:- pred find_prefix_end(string::in, char::in, int::in, int::out) is semidet.
find_prefix_end(Str, PrevC, I0, I) :-
string.unsafe_index_next(Str, I0, I1, C),
% We'll assume the first digit following whitespace begins the year ranges.
( if
char.is_digit(C),
char.is_whitespace(PrevC)
then
I = I0
else
find_prefix_end(Str, C, I1, I)
).
:- pred find_suffix_start(string::in, int::in, int::in, int::out) is semidet.
find_suffix_start(Str, I0, LastNonWs, I) :-
( if string.unsafe_index_next(Str, I0, I1, C) then
( if
( char.is_digit(C)
; C = ('-')
; C = (',')
)
then
find_suffix_start(Str, I1, I1, I)
else if char.is_whitespace(C) then
find_suffix_start(Str, I1, LastNonWs, I)
else
I = LastNonWs
)
else
I = LastNonWs
).
:- pred parse_ranges(string::in, list(year_range)::out) is semidet.
parse_ranges(Str, Ranges) :-
Words = string.words_separator(is_whitespace_or_comma, Str),
list.map(parse_range, Words, Ranges).
:- pred is_whitespace_or_comma(char::in) is semidet.
is_whitespace_or_comma(C) :-
( char.is_whitespace(C)
; C = (',')
).
:- pred parse_range(string::in, year_range::out) is semidet.
parse_range(Str, Range) :-
Words = string.split_at_char('-', Str),
(
Words = [S],
string.to_int(S, N),
Range = years(N, N)
;
Words = [S1, S2],
string.to_int(S1, N1),
string.to_int(S2, N2),
N1 =< N2,
Range = years(N1, N2)
).
:- pred merge_adjacent_ranges_if_possible(
list(year_range)::in, list(year_range)::out) is det.
merge_adjacent_ranges_if_possible(Ranges0, Ranges) :-
(
( Ranges0 = []
; Ranges0 = [_]
),
Ranges = Ranges0
;
Ranges0 = [Range1, Range2 | Ranges3plus],
Range1 = years(RangeLo1, RangeHi1),
Range2 = years(RangeLo2, RangeHi2),
( if RangeLo2 =< RangeHi1 + 1 then
Range12 = years(RangeLo1, RangeHi2),
merge_adjacent_ranges_if_possible([Range12 | Ranges3plus], Ranges)
else
merge_adjacent_ranges_if_possible([Range2 | Ranges3plus],
TailRanges),
Ranges = [Range1 | TailRanges]
)
).
:- pred add_to_ranges(int::in, list(year_range)::in, list(year_range)::out)
is semidet.
add_to_ranges(Year, Ranges0, Ranges) :-
(
Ranges0 = [],
Ranges = [years(Year, Year)]
;
Ranges0 = [R0 | Ranges1],
( if year_in_range(Year, R0) then
fail
else if extend_range(Year, R0, R) then
Ranges = [R | Ranges1]
else
add_to_ranges(Year, Ranges1, Ranges2),
Ranges = [R0 | Ranges2]
)
).
:- pred year_in_range(int::in, year_range::in) is semidet.
year_in_range(Year, Range) :-
Range = years(Lo, Hi),
Year >= Lo,
Year =< Hi.
:- pred extend_range(int::in, year_range::in, year_range::out) is semidet.
extend_range(Year, Range0, Range) :-
Range0 = years(Lo, Hi),
Year = Hi + 1,
Range = years(Lo, Year).
:- pred make_copyright_line(string::in, list(year_range)::in, string::in,
string::out) is det.
make_copyright_line(Prefix, Ranges, Suffix, Line) :-
Mid = string.join_list(", ", list.map(range_to_string, Ranges)),
Line = Prefix ++ Mid ++ Suffix.
:- func range_to_string(year_range) = string.
range_to_string(Range) = Str :-
Range = years(Lo, Hi),
( if Lo = Hi then
Str = string.from_int(Lo)
else
Str = string.from_int(Lo) ++ "-" ++ string.from_int(Hi)
).
%---------------------------------------------------------------------------%
:- pred report_io_error(string::in, io.error::in, io::di, io::uo) is det.
report_io_error(Prefix, Error, !IO) :-
Message = Prefix ++ ": " ++ io.error_message(Error),
report_error_message(Message, !IO).
:- pred report_error_message(string::in, io::di, io::uo) is det.
report_error_message(Message, !IO) :-
io.stderr_stream(ErrorStream, !IO),
io.write_string(ErrorStream, Message, !IO),
io.nl(ErrorStream, !IO),
io.set_exit_status(2, !IO).
:- pred maybe_set_modified_exit_status(io::di, io::uo) is det.
maybe_set_modified_exit_status(!IO) :-
% Don't override exit status for general errors.
io.get_exit_status(ExitStatus0, !IO),
( if ExitStatus0 = 0 then
io.set_exit_status(1, !IO)
else
true
).
%---------------------------------------------------------------------------%
:- end_module update_copyright.
%---------------------------------------------------------------------------%