1 #!/usr/bin/env rdmd
2 
3 /**
4 Update the copyright notices in source files so that they have the form:
5 ---
6     Copyright XXXX-YYYY by The D Language Foundation, All Rights Reserved
7 ---
8 It does not change copyright notices of authors that are known to have made
9 changes under a proprietary license.
10 
11 Copyright:  Copyright (C) 2017-2018 by The D Language Foundation, All Rights Reserved
12 
13 License:    $(WEB boost.org/LICENSE_1_0.txt, Boost License 1.0)
14 
15 Authors:    Iain Buclaw
16 
17 Example usage:
18 
19 ---
20 updatecopyright.d --update-year src/dmd
21 ---
22 */
23 
24 module tools.updatecopyright;
25 
26 int main(string[] args)
27 {
28     import std.getopt;
29 
30     bool updateYear;
31     bool verbose;
32     auto opts = getopt(args,
33         "update-year|y", "Update the current year on every notice", &updateYear,
34         "verbose|v", "Be more verbose", &verbose);
35 
36     if (args.length == 1 || opts.helpWanted)
37     {
38         defaultGetoptPrinter("usage: updatecopyright [--help|-h] [--update-year|-y] <dir>...",
39                              opts.options);
40         return 0;
41     }
42 
43     Copyright(updateYear, verbose).run(args[1 .. $]);
44     return 0;
45 }
46 
47 //::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
48 
49 struct Copyright
50 {
51     import std.algorithm : any, canFind, each, filter, joiner, map;
52     import std.array : appender, array;
53     import std.file : DirEntry, SpanMode, dirEntries, remove, rename;
54     import std.stdio : File, stderr, stdout;
55     import std.string : endsWith, strip, stripLeft, stripRight;
56     import std.regex : Regex, matchAll, regex;
57 
58     // The author to use in copyright notices.
59     enum author = "The D Language Foundation, All Rights Reserved";
60 
61     // The standard (C) form.
62     enum copyright = "(C)";
63 
64 private:
65     // True if running in verbose mode.
66     bool verbose = false;
67 
68     // True if also updating copyright year.
69     bool updateYear = false;
70 
71     // An associative array of known copyright holders.
72     // Value set to true if the copyright holder is internal.
73     bool[string] holders;
74 
75     // Files and directories to ignore during search.
76     static string[] skipDirs = [
77         "docs",
78         "ini",
79         "test",
80         "samples",
81         "vcbuild",
82         ".git",
83     ];
84 
85     static string[] skipFiles = [
86         "Jenkinsfile",
87         "LICENSE.txt",
88         "VERSION",
89         ".a",
90         ".ddoc",
91         ".deps",
92         ".lst",
93         ".map",
94         ".md",
95         ".o",
96         ".obj",
97         ".sdl",
98         ".sh",
99         ".yml",
100     ];
101 
102     // Characters in a range of years.
103     // Include '.' for typos, and '?' for unknown years.
104     enum rangesStr = `[0-9?](?:[-0-9.,\s]|\s+and\s+)*[0-9]`;
105 
106     // Non-whitespace characters in a copyright holder's name.
107     enum nameStr = `[\w.,-]`;
108 
109     // Matches a full copyright notice:
110     // - 'Copyright (C)', etc.
111     // - The years. Includes the whitespace in the year, so that we can
112     //   remove any excess.
113     // - 'by ', if used
114     // - The copyright holder.
115     Regex!char copyrightRe;
116 
117     // A regexp for notices that might have slipped by.
118     Regex!char otherCopyrightRe;
119 
120     // A regexp that matches one year.
121     Regex!char yearRe;
122 
123     // Matches part of a year or copyright holder.
124     Regex!char continuationRe;
125 
126     Regex!char commentRe;
127 
128     // Convenience for passing around file/line number information.
129     struct FileLocation
130     {
131         string filename;
132         size_t linnum;
133 
134         string toString()
135         {
136             import std.format : format;
137             return "%s(%d)".format(this.filename, this.linnum);
138         }
139     }
140 
141     FileLocation location;
142     char[] previousLine;
143 
144     void processFile(string filename)
145     {
146         import std.conv : to;
147 
148         // Looks like something we tried to create before.
149         if (filename.endsWith(".tmp"))
150         {
151             remove(filename);
152             return;
153         }
154 
155         auto file = File(filename, "rb");
156         auto output = appender!string;
157         int errors = 0;
158         bool changed = false;
159 
160         output.reserve(file.size.to!size_t);
161 
162         // Reset file location information.
163         this.location = FileLocation(filename, 0);
164         this.previousLine = null;
165 
166         foreach (line; file.byLine)
167         {
168             this.location.linnum++;
169             try
170             {
171                 changed |= this.processLine(line, output, errors);
172             }
173             catch (Exception)
174             {
175                 if (this.verbose)
176                     stderr.writeln(filename, ": bad input file");
177                 errors++;
178                 break;
179             }
180         }
181         file.close();
182 
183         // If something changed, write the new file out.
184         if (changed && !errors)
185         {
186             auto tmpfilename = filename ~ ".tmp";
187             auto tmpfile = File(tmpfilename, "w");
188             tmpfile.write(output.data);
189             tmpfile.close();
190             rename(tmpfilename, filename);
191         }
192     }
193 
194     bool processLine(String, Array)(String line, ref Array output, ref int errors)
195     {
196         bool changed = false;
197 
198         if (this.previousLine)
199         {
200             auto continuation = this.stripContinuation(line);
201 
202             // Merge the lines for matching purposes.
203             auto mergedLine = this.previousLine.stripRight() ~ `, ` ~ continuation;
204             auto mergedMatch = mergedLine.matchAll(copyrightRe);
205 
206             if (!continuation.matchAll(this.continuationRe) ||
207                 !mergedMatch || !this.isComplete(mergedMatch))
208             {
209                 // If the next line doesn't look like a proper continuation,
210                 // assume that what we've got is complete.
211                 auto match = this.previousLine.matchAll(copyrightRe);
212                 changed |= this.updateCopyright(line, match, errors);
213                 output.put(this.previousLine);
214                 output.put('\n');
215             }
216             else
217             {
218                 line = mergedLine;
219             }
220             this.previousLine = null;
221         }
222 
223         auto match = line.matchAll(copyrightRe);
224         if (match)
225         {
226             // If it looks like the copyright is incomplete, add the next line.
227             if (!this.isComplete(match))
228             {
229                 this.previousLine = line.dup;
230                 return changed;
231             }
232             changed |= this.updateCopyright(line, match, errors);
233         }
234         else if (line.matchAll(this.otherCopyrightRe))
235         {
236             stderr.writeln(this.location, ": unrecognised copyright: ", line.strip);
237             //errors++; // Only treat this as a warning for now...
238         }
239         output.put(line);
240         output.put('\n');
241 
242         return changed;
243     }
244 
245     String stripContinuation(String)(String line)
246     {
247         line = line.stripLeft();
248         auto match = line.matchAll(this.commentRe);
249         if (match)
250         {
251             auto captures = match.front;
252             line = captures.post.stripLeft();
253         }
254         return line;
255     }
256 
257     bool isComplete(Match)(Match match)
258     {
259         auto captures = match.front;
260         return captures.length >= 5 && captures[4] in this.holders;
261     }
262 
263     bool updateCopyright(String, Match)(ref String line, Match match, ref int errors)
264     {
265         auto captures = match.front;
266         if (captures.length < 5)
267         {
268             stderr.writeln(this.location, ": missing copyright holder");
269             errors++;
270             return false;
271         }
272 
273         // See if copyright is associated with package author.
274         // Update the author so as to be consistent everywhere.
275         auto holder = captures[4];
276         if (holder !in this.holders)
277         {
278             stderr.writeln(this.location, ": unrecognised copyright holder: ", holder);
279             errors++;
280             return false;
281         }
282         else if (!this.holders[holder])
283             return false;
284 
285         // Update the copyright years.
286         auto years = captures[2].strip;
287         if (!this.canonicalizeYears(years))
288         {
289             stderr.writeln(this.location, ": unrecognised year string: ", years);
290             errors++;
291             return false;
292         }
293 
294         // Make sure (C) is present.
295         auto intro = captures[1];
296         if (intro.endsWith("right"))
297             intro ~= " " ~ this.copyright;
298         else if (intro.endsWith("(c)"))
299             intro = intro[0 .. $ - 3] ~ this.copyright;
300 
301         // Construct the copyright line, removing any 'by '.
302         auto newline = captures.pre ~ intro ~ " " ~ years ~ " by " ~ this.author ~ captures.post;
303         if (line != newline)
304         {
305             line = newline;
306             return true;
307         }
308         return false;
309     }
310 
311     bool canonicalizeYears(String)(ref String years)
312     {
313         import std.conv : to;
314         import std.datetime : Clock;
315 
316         auto yearList = years.matchAll(this.yearRe).map!(m => m.front).array;
317         if (yearList.length > 0)
318         {
319             auto minYear = yearList[0];
320             auto maxYear = yearList[$ - 1];
321 
322             // Update the upper bound, if enabled.
323             if (this.updateYear)
324                 maxYear = to!String(Clock.currTime.year);
325 
326             // Use a range.
327             if (minYear == maxYear)
328                 years = minYear;
329             else
330                 years = minYear ~ "-" ~ maxYear;
331             return true;
332         }
333         return false;
334     }
335 
336 public:
337     this(bool updateYear, bool verbose)
338     {
339         this.updateYear = updateYear;
340         this.verbose = verbose;
341 
342         this.copyrightRe = regex(`([Cc]opyright` ~ `|[Cc]opyright\s+\([Cc]\))` ~
343                                  `(\s*(?:` ~ rangesStr ~ `,?)\s*)` ~
344                                  `(by\s+)?` ~
345                                  `(` ~ nameStr ~ `(?:\s?` ~ nameStr ~ `)*)?`);
346         this.otherCopyrightRe = regex(`copyright.*[0-9][0-9]`, `i`);
347         this.yearRe = regex(`[0-9?]+`);
348         this.continuationRe = regex(rangesStr ~ `|` ~ nameStr);
349         this.commentRe = regex(`#+|[*]+|;+|//+`);
350 
351         this.holders = [
352             "Digital Mars" : true,
353             "Digital Mars, All Rights Reserved" : true,
354             "The D Language Foundation, All Rights Reserved" : true,
355             "The D Language Foundation" : true,
356 
357             // List of external authors.
358             "Northwest Software" : false,
359             "RSA Data Security, Inc. All rights reserved." : false,
360             "Symantec" : false,
361         ];
362     }
363 
364     // Main loop.
365     void run(string[] args)
366     {
367         // Returns true if entry should be skipped for processing.
368         bool skipPath(DirEntry entry)
369         {
370             import std.path : baseName, dirName, pathSplitter;
371 
372             if (!entry.isFile)
373                 return true;
374 
375             if (entry.dirName.pathSplitter.filter!(d => this.skipDirs.canFind(d)).any)
376                 return true;
377 
378             auto basename = entry.baseName;
379             if (this.skipFiles.canFind!(s => basename.endsWith(s)))
380             {
381                 if (this.verbose)
382                     stderr.writeln(entry, ": skipping file");
383                 return true;
384             }
385             return false;
386         }
387 
388         args.map!(arg => arg.dirEntries(SpanMode.depth).filter!(a => !skipPath(a)))
389             .joiner.each!(f => this.processFile(f));
390     }
391 }