[m-rev.] for review: Add a tool to help maintain Copyright lines.

Peter Wang novalazy at gmail.com
Fri Feb 9 15:16:30 AEDT 2024


tools/update_copyright.m:
tools/.gitignore:
    Add the update_copyright program. It is deliberately not integrated
    into the build system as it should be compiled once, manually, then
    left in the workspace across checkouts, mmake clean, etc.

tools/update_copyright.pre-commit:
    Add a sample git pre-commit hook. It is up to the developer to
    enable the hook in his/her workspace, if desired.
---
 tools/.gitignore                  |  14 ++
 tools/update_copyright.m          | 356 ++++++++++++++++++++++++++++++
 tools/update_copyright.pre-commit |  32 +++
 3 files changed, 402 insertions(+)
 create mode 100644 tools/update_copyright.m
 create mode 100755 tools/update_copyright.pre-commit

diff --git a/tools/.gitignore b/tools/.gitignore
index a0615eb2a..c44824879 100644
--- a/tools/.gitignore
+++ b/tools/.gitignore
@@ -1,2 +1,16 @@
 lmc
 dotime
+update_copyright
+update_copyright.c
+update_copyright.c_date
+update_copyright.d
+update_copyright.date
+update_copyright.dep*
+update_copyright.dv
+update_copyright.err
+update_copyright.int
+update_copyright.int2
+update_copyright.mh
+update_copyright.mih
+update_copyright.o
+update_copyright_init.*
diff --git a/tools/update_copyright.m b/tools/update_copyright.m
new file mode 100644
index 000000000..93ef3e510
--- /dev/null
+++ b/tools/update_copyright.m
@@ -0,0 +1,356 @@
+%---------------------------------------------------------------------------%
+% 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.
+%---------------------------------------------------------------------------%
+%
+% update_copyright [OPTIONS] [FILES]...
+%
+% Options:
+%
+%   -s, --suffix STR    Match only Copyright lines with STR in the suffix.
+%
+% 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.
+%
+%---------------------------------------------------------------------------%
+
+:- 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
+    --->    suffix
+    ;       dummy.
+
+:- type mod_state
+    --->    unmodified
+    ;       found_unmodified
+    ;       found_modified.
+
+%---------------------------------------------------------------------------%
+
+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_maybe_string_option(OptionTable, suffix,
+            MaybeExpectSuffix),
+        (
+            NonOptionArgs = [],
+            io.input_stream(InputStream, !IO),
+            read_lines_loop(InputStream, Year, MaybeExpectSuffix,
+                [], RevLines, unmodified, _ModState, !IO),
+            io.output_stream(OutputStream, !IO),
+            write_rev_lines(OutputStream, RevLines, !IO)
+        ;
+            NonOptionArgs = [_ | _],
+            process_files(Year, MaybeExpectSuffix, 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('s', suffix).
+
+:- pred long_option(string::in, option::out) is semidet.
+
+long_option("suffix", suffix).
+
+:- pred option_default(option::out, option_data::out) is multi.
+
+option_default(suffix, maybe_string(no)).
+option_default(dummy, int(0)).
+
+%---------------------------------------------------------------------------%
+
+:- 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_files(int::in, maybe(string)::in, list(string)::in,
+    io::di, io::uo) is det.
+
+process_files(_CurrentYear, _MaybeExpectSuffix, [], !IO).
+process_files(CurrentYear, MaybeExpectSuffix, [FileName | FileNames], !IO) :-
+    process_file(CurrentYear, MaybeExpectSuffix, FileName, Continue, !IO),
+    (
+        Continue = yes,
+        process_files(CurrentYear, MaybeExpectSuffix, FileNames, !IO)
+    ;
+        Continue = no
+    ).
+
+:- pred process_file(int::in, maybe(string)::in, string::in, bool::out,
+    io::di, io::uo) is det.
+
+process_file(CurrentYear, MaybeExpectSuffix, FileName, Continue, !IO) :-
+    io.open_input(FileName, OpenInputRes, !IO),
+    (
+        OpenInputRes = ok(InputStream),
+        read_lines_loop(InputStream, CurrentYear, MaybeExpectSuffix,
+            [], RevLines, unmodified, ModState, !IO),
+        io.close_input(InputStream, !IO),
+        (
+            ( ModState = unmodified
+            ; ModState = found_unmodified
+            ),
+            io.format("Unchanged: %s\n", [s(FileName)], !IO),
+            Continue = yes
+        ;
+            ModState = found_modified,
+            % It would be better to write a temporary file then atomically
+            % rename over 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),
+                write_rev_lines(OutputStream, RevLines, !IO),
+                io.close_output(OutputStream, !IO),
+                io.format("Modified: %s\n", [s(FileName)], !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, int::in, maybe(string)::in,
+    list(string)::in, list(string)::out, mod_state::in, mod_state::out,
+    io::di, io::uo) is det.
+
+read_lines_loop(InputStream, CurrentYear, MaybeExpectSuffix,
+        !AccLines, !ModState, !IO) :-
+    io.read_line_as_string(InputStream, ReadRes, !IO),
+    (
+        ReadRes = ok(Line),
+        ( if
+            !.ModState = unmodified,
+            parse_copyright_line(Line, MaybeExpectSuffix,
+                Prefix, Ranges0, Suffix)
+        then
+            % We could normalise ranges here.
+            sort(Ranges0, Ranges1),
+            ( if add_to_ranges(CurrentYear, Ranges1, Ranges) then
+                make_copyright_line(Prefix, Ranges, Suffix, NewLine),
+                !:AccLines = [NewLine | !.AccLines],
+                !:ModState = found_modified
+            else
+                !:AccLines = [Line | !.AccLines],
+                !:ModState = found_unmodified
+            )
+        else
+            !:AccLines = [Line | !.AccLines]
+        ),
+        read_lines_loop(InputStream, CurrentYear, MaybeExpectSuffix,
+            !AccLines, !ModState, !IO)
+    ;
+        ReadRes = eof
+    ;
+        ReadRes = error(Error),
+        report_io_error("Error reading", Error, !IO)
+    ).
+
+:- pred parse_copyright_line(string::in, maybe(string)::in,
+    string::out, list(year_range)::out, string::out) is semidet.
+
+parse_copyright_line(Line, MaybeExpectSuffix, Prefix, Ranges, Suffix) :-
+    string.sub_string_search(Line, "Copyright ", AfterCopyright),
+    find_prefix_end(Line, ' ', AfterCopyright, PrefixEnd),
+    find_suffix_start(Line, PrefixEnd, PrefixEnd, SuffixStart),
+    (
+        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 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 is_whitespace_or_comma(char::in) is semidet.
+
+is_whitespace_or_comma(C) :-
+    ( char.is_whitespace(C)
+    ; C = (',')
+    ).
+
+:- 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 write_rev_lines(io.output_stream::in, list(string)::in,
+    io::di, io::uo) is det.
+
+write_rev_lines(Stream, RevLines, !IO) :-
+    list.foldr(io.write_string(Stream), RevLines, !IO).
+
+:- 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(1, !IO).
+
+%---------------------------------------------------------------------------%
+:- end_module update_copyright.
+%---------------------------------------------------------------------------%
diff --git a/tools/update_copyright.pre-commit b/tools/update_copyright.pre-commit
new file mode 100755
index 000000000..af02913ee
--- /dev/null
+++ b/tools/update_copyright.pre-commit
@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+#
+# You can copy/symlink this script to .git/hooks/pre-commit.
+# It will update the Copyright lines of all *.m files that are going to be
+# modified in the commit, before the commit is made.
+#
+# You need to compile the update_copyright.m program:
+#   mmc --mercury-linkage static update_copyright.m
+#
+# You can clean the intermediate files with:
+#   git clean -fx 'update_copyright.*' 'update_copyright_init.*'
+#
+set -e
+
+rootdir=$( git rev-parse --show-toplevel )
+update_copyright="$rootdir/tools/update_copyright"
+
+# Only continue if the update_copyright program has been compiled.
+if [ ! -x "$update_copyright" ]; then
+    exit
+fi
+
+# Find changed files for this commit.
+changed_files=($(git diff --name-only --cached --diff-filter=ACMR -- \
+    '*.[mchly]' '*.java' ))
+
+if [ "${#changed_files}" -eq 0 ]; then
+    exit
+fi
+
+"$update_copyright" --suffix 'Mercury team' -- "${changed_files[@]}" &&
+git add -u -- "${changed_files[@]}"
-- 
2.43.0



More information about the reviews mailing list