1// Copyright © SixtyFPS GmbH <info@slint.dev>
2// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
3
4// cSpell: ignore datetime dotdot gettext
5
6use anyhow::anyhow;
7use anyhow::Context;
8use anyhow::Result;
9use lazy_static::lazy_static;
10use std::cell::RefCell;
11use std::str::FromStr;
12use std::{path::Path, path::PathBuf};
13
14#[derive(Copy, Clone, Debug)]
15struct LicenseTagStyle {
16 tag_start: &'static str,
17 line_prefix: &'static str,
18 line_indentation: &'static str,
19 line_break: &'static str,
20 tag_end: &'static str,
21 overall_start: &'static str,
22 overall_end: &'static str,
23 is_real_end: bool,
24}
25
26impl LicenseTagStyle {
27 fn c_style_comment_style() -> Self {
28 Self {
29 tag_start: "// Copyright © ",
30 line_prefix: "//",
31 line_indentation: " ",
32 line_break: "\n",
33 tag_end: const_format::concatcp!("// ", SPDX_LICENSE_LINE),
34 overall_start: "",
35 overall_end: "\n",
36 is_real_end: false,
37 }
38 }
39
40 fn shell_comment_style() -> Self {
41 Self {
42 tag_start: "# Copyright © ",
43 line_prefix: "#",
44 line_indentation: " ",
45 line_break: "\n",
46 tag_end: const_format::concatcp!("# ", SPDX_LICENSE_LINE),
47 overall_start: "",
48 overall_end: "\n",
49 is_real_end: false,
50 }
51 }
52
53 fn rst_comment_style() -> Self {
54 Self {
55 tag_start: ".. Copyright © ",
56 line_prefix: "..",
57 line_indentation: " ",
58 line_break: "\n",
59 tag_end: const_format::concatcp!(".. ", SPDX_LICENSE_LINE),
60 overall_start: "",
61 overall_end: "\n",
62 is_real_end: false,
63 }
64 }
65
66 fn html_comment_style() -> Self {
67 Self {
68 tag_start: "<!-- Copyright © ",
69 line_prefix: " ",
70 line_indentation: "",
71 line_break: " ;",
72 tag_end: " -->",
73 overall_start: "<!--",
74 overall_end: " -->\n",
75 is_real_end: true,
76 }
77 }
78}
79
80struct SourceFileWithTags<'a> {
81 source: &'a str,
82 tag_style: &'a LicenseTagStyle,
83 tag_location: Option<std::ops::Range<usize>>,
84}
85
86impl<'a> SourceFileWithTags<'a> {
87 fn new(source: &'a str, style: &'a LicenseTagStyle) -> Self {
88 // This assumes that all LicenseTagStyle end with a SPDX license identifier line!
89 let location = match source.find(style.tag_start) {
90 Some(start) => {
91 let end_tag = source[start..]
92 .find(style.tag_end)
93 .map(|idx| start + idx + style.tag_end.len())
94 .unwrap_or_default();
95 if end_tag > start {
96 if style.is_real_end {
97 if source.as_bytes()[end_tag] == b'\n' {
98 Some(std::ops::Range { start, end: end_tag + 1 })
99 } else {
100 Some(std::ops::Range { start, end: end_tag })
101 }
102 } else {
103 let end = source[end_tag..]
104 .find('\n')
105 .map(|idx| end_tag + idx + 1)
106 .unwrap_or(end_tag);
107 Some(std::ops::Range { start, end })
108 }
109 } else {
110 None
111 }
112 }
113 None => None,
114 };
115
116 // Find default gettext copyright statements
117 let location = location.or_else(|| {
118 let Some(start) =
119 source.find("# SOME DESCRIPTIVE TITLE").or_else(|| source.find("# Copyright (C) "))
120 else {
121 return None;
122 };
123 let end_line = "# This file is distributed under the same license as the ";
124 let Some(end) = source[start..].find(end_line) else {
125 return None;
126 };
127 let end = start + end + end_line.len();
128 let Some(end_nl) = source[end..].find('\n') else {
129 return None;
130 };
131 Some(std::ops::Range { start, end: end + end_nl + 1 })
132 });
133
134 Self { source, tag_style: style, tag_location: location }
135 }
136
137 fn has_tag(&self) -> bool {
138 self.tag_location.is_some()
139 }
140
141 fn found_tag(&self) -> &'a str {
142 let tag_loc = match &self.tag_location {
143 Some(loc) => loc,
144 None => return "",
145 };
146
147 &self.source[tag_loc.start..tag_loc.end]
148 }
149
150 fn has_license_header(&self, expected_tag: &LicenseHeader) -> bool {
151 let tag_loc = match &self.tag_location {
152 Some(loc) => loc,
153 None => return false,
154 };
155
156 let found_tag = &self.source[tag_loc.start..tag_loc.end]
157 .trim_start_matches(self.tag_style.overall_start)
158 .trim_end_matches(self.tag_style.overall_end);
159 let mut tag_entries = found_tag.split(self.tag_style.line_break);
160 let Some(_copyright_entry) = tag_entries.next() else { return false };
161 // Require _some_ license ...
162 let Some(_) = tag_entries.next() else { return false };
163 // ... as well as the SPDX license line at the start
164 expected_tag.0 == SPDX_LICENSE_LINE
165 }
166
167 fn replace_tag(&self, replacement: &LicenseHeader, license: &str) -> String {
168 let loc = &self.tag_location;
169 let next_char = if let Some(range) = loc {
170 self.source.as_bytes().get(range.end)
171 } else {
172 self.source.as_bytes().first()
173 };
174
175 let existing_copyright = loc.as_ref().and_then(|tag_loc| {
176 self.source[tag_loc.start..tag_loc.end]
177 .trim_start_matches(self.tag_style.overall_start)
178 .trim_end_matches(self.tag_style.overall_end)
179 .split(self.tag_style.line_break)
180 .next()
181 });
182
183 let new_header = [
184 self.tag_style.overall_start,
185 &existing_copyright.map_or_else(
186 || {
187 [
188 self.tag_style.line_prefix,
189 self.tag_style.line_indentation,
190 "Copyright © SixtyFPS GmbH <info@slint.dev>",
191 ]
192 .concat()
193 },
194 ToString::to_string,
195 ),
196 self.tag_style.line_break,
197 &replacement.to_string(self.tag_style, license),
198 self.tag_style.overall_end,
199 ]
200 .concat();
201 let new_header = if next_char == Some(&b'\n') || next_char.is_none() {
202 new_header
203 } else {
204 format!("{new_header}\n")
205 };
206
207 match loc {
208 Some(loc) => {
209 self.source[0..loc.start].to_string() + &new_header + &self.source[loc.end..]
210 }
211 None => new_header + self.source,
212 }
213 }
214}
215
216#[test]
217fn test_license_tag_c_style() {
218 let style = LicenseTagStyle::c_style_comment_style();
219 {
220 let source = format!(
221 r#"// Copyright © something <bar@something.com>
222foobar
223// SP{}-License-Identifier: {}
224blah"#,
225 "DX", EXPECTED_SPDX_EXPRESSION
226 );
227 let test_source = SourceFileWithTags::new(&source, &style);
228 assert_eq!(
229 test_source.replace_tag(&LicenseHeader("TEST_LICENSE"), "foo"),
230 r#"// Copyright © something <bar@something.com>
231// TEST_LICENSE
232
233blah"#
234 .to_string()
235 );
236 }
237 {
238 let source = format!(
239 r#"// Copyright © something <bar@something.com>
240foobar
241// SP{}-License-Identifier: {}
242
243blah"#,
244 "DX", EXPECTED_SPDX_EXPRESSION
245 );
246 let test_source = SourceFileWithTags::new(&source, &style);
247 assert_eq!(
248 test_source.replace_tag(&LicenseHeader("TEST_LICENSE"), "bar"),
249 r#"// Copyright © something <bar@something.com>
250// TEST_LICENSE
251
252blah"#
253 .to_string()
254 );
255 }
256 {
257 let test_source = SourceFileWithTags::new("blah", &style);
258 assert_eq!(
259 test_source.replace_tag(&LicenseHeader("TEST_LICENSE"), "bar"),
260 r#"// Copyright © SixtyFPS GmbH <info@slint.dev>
261// TEST_LICENSE
262
263blah"#
264 .to_string()
265 );
266 }
267 {
268 let test_source = SourceFileWithTags::new("\nblah", &style);
269 assert_eq!(
270 test_source.replace_tag(&LicenseHeader(SPDX_LICENSE_LINE), "bar"),
271 String::from("// Copyright © SixtyFPS GmbH <info@slint.dev>\n// ")
272 + SPDX_LICENSE_LINE
273 + r#"bar
274
275blah"#
276 );
277 }
278 {
279 let test_source = SourceFileWithTags::new("", &style);
280 assert_eq!(
281 test_source.replace_tag(&LicenseHeader("TEST_LICENSE"), "bar"),
282 r#"// Copyright © SixtyFPS GmbH <info@slint.dev>
283// TEST_LICENSE
284"#
285 .to_string()
286 );
287 }
288}
289
290#[test]
291fn test_license_tag_hash() {
292 let style = LicenseTagStyle::shell_comment_style();
293 {
294 let source = format!(
295 r#"# Copyright © something <bar@something.com>
296foobar
297# SP{}-License-Identifier: {}
298
299blah"#,
300 "DX", EXPECTED_SPDX_EXPRESSION
301 );
302 let test_source = SourceFileWithTags::new(&source, &style);
303 assert_eq!(
304 test_source.replace_tag(&LicenseHeader("TEST_LICENSE"), "bar"),
305 r#"# Copyright © something <bar@something.com>
306# TEST_LICENSE
307
308blah"#
309 .to_string()
310 );
311 }
312 {
313 let test_source = SourceFileWithTags::new(r#"blah"#, &style);
314 assert_eq!(
315 test_source.replace_tag(&LicenseHeader("TEST_LICENSE"), "bar"),
316 r#"# Copyright © SixtyFPS GmbH <info@slint.dev>
317# TEST_LICENSE
318
319blah"#
320 .to_string()
321 );
322 }
323}
324
325#[test]
326fn test_license_tag_dotdot() {
327 let style = LicenseTagStyle::rst_comment_style();
328 {
329 let source = format!(
330 r#".. Copyright © something <bar@something.com>
331foobar
332.. SP{}-License-Identifier: {}
333
334blah"#,
335 "DX", EXPECTED_SPDX_EXPRESSION
336 );
337 let test_source = SourceFileWithTags::new(&source, &style);
338 assert_eq!(
339 test_source.replace_tag(&LicenseHeader("TEST_LICENSE"), "bar"),
340 r#".. Copyright © something <bar@something.com>
341.. TEST_LICENSE
342
343blah"#
344 .to_string()
345 );
346 }
347 {
348 let test_source = SourceFileWithTags::new(r#"blah"#, &style);
349 assert_eq!(
350 test_source.replace_tag(&LicenseHeader("TEST_LICENSE"), "bar"),
351 r#".. Copyright © SixtyFPS GmbH <info@slint.dev>
352.. TEST_LICENSE
353
354blah"#
355 .to_string()
356 );
357 }
358}
359
360#[test]
361fn test_license_tag_html_style() {
362 let style = LicenseTagStyle::html_comment_style();
363 {
364 let source = format!(
365 r#"<!-- Copyright © something <bar@something.com> ; SP{}-License-Identifier: {} -->
366blah"#,
367 "DX", EXPECTED_SPDX_EXPRESSION
368 );
369 let test_source = SourceFileWithTags::new(&source, &style);
370 assert_eq!(
371 test_source.replace_tag(&LicenseHeader("TEST_LICENSE"), "foo"),
372 r#"<!-- Copyright © something <bar@something.com> ; TEST_LICENSE -->
373
374blah"#
375 .to_string()
376 );
377 }
378 {
379 let source = format!(
380 r#"<!-- Copyright © something <bar@something.com> ; SP{}-License-Identifier: {} -->
381
382blah"#,
383 "DX", EXPECTED_SPDX_EXPRESSION
384 );
385 let test_source = SourceFileWithTags::new(&source, &style);
386 assert_eq!(
387 test_source.replace_tag(&LicenseHeader("TEST_LICENSE"), "bar"),
388 r#"<!-- Copyright © something <bar@something.com> ; TEST_LICENSE -->
389
390blah"#
391 .to_string()
392 );
393 }
394 {
395 let test_source = SourceFileWithTags::new("blah", &style);
396 assert_eq!(
397 test_source.replace_tag(&LicenseHeader("TEST_LICENSE"), "bar"),
398 r#"<!-- Copyright © SixtyFPS GmbH <info@slint.dev> ; TEST_LICENSE -->
399
400blah"#
401 .to_string()
402 );
403 }
404 {
405 let test_source = SourceFileWithTags::new("\nblah", &style);
406 assert_eq!(
407 test_source.replace_tag(&LicenseHeader(SPDX_LICENSE_LINE), "bar"),
408 String::from("<!-- Copyright © SixtyFPS GmbH <info@slint.dev> ; ")
409 + SPDX_LICENSE_LINE
410 + r#"bar -->
411
412blah"#
413 );
414 }
415 {
416 let test_source = SourceFileWithTags::new("", &style);
417 assert_eq!(
418 test_source.replace_tag(&LicenseHeader("TEST_LICENSE"), "bar"),
419 r#"<!-- Copyright © SixtyFPS GmbH <info@slint.dev> ; TEST_LICENSE -->
420"#
421 .to_string()
422 );
423 }
424}
425
426#[derive(Copy, Clone, Debug)]
427enum LicenseLocation {
428 Tag(LicenseTagStyle),
429 Crate,
430 NoLicense,
431}
432
433lazy_static! {
434 // cspell:disable
435 static ref LICENSE_LOCATION_FOR_FILE: Vec<(regex::Regex, LicenseLocation)> = [
436 // full matches
437 ("^\\.cargo/config$", LicenseLocation::Tag(LicenseTagStyle::shell_comment_style())),
438 ("^\\.clang-format$", LicenseLocation::NoLicense),
439 ("^\\.github/.*\\.md$", LicenseLocation::NoLicense),
440 ("^\\.mailmap$", LicenseLocation::NoLicense),
441 ("^\\.mise/tasks/", LicenseLocation::Tag(LicenseTagStyle::shell_comment_style())),
442 ("^api/cpp/docs/conf\\.py$", LicenseLocation::NoLicense),
443 ("^docs/reference/Pipfile$", LicenseLocation::NoLicense),
444 ("^docs/reference/conf\\.py$", LicenseLocation::NoLicense),
445 ("^editors/vscode/src/snippets\\.ts$", LicenseLocation::NoLicense), // liberal license
446 ("^editors/vscode/tests/grammar/.*\\.slint$", LicenseLocation::NoLicense), // License header breaks these tests
447 ("^editors/tree-sitter-slint/binding\\.gyp$", LicenseLocation::NoLicense), // liberal license
448 ("^editors/tree-sitter-slint/test-to-corpus\\.py$", LicenseLocation::Tag(LicenseTagStyle::shell_comment_style())),
449 ("^Cargo\\.lock$", LicenseLocation::NoLicense),
450 ("^demos/printerdemo/zephyr/VERSION$", LicenseLocation::NoLicense),
451 ("^examples/mcu-board-support/pico2_st7789/rp_pico2.rs$", LicenseLocation::NoLicense), // third-party file
452
453 // filename based matches:
454 ("(^|/)CMakeLists\\.txt$", LicenseLocation::Tag(LicenseTagStyle::shell_comment_style())),
455 ("(^|/)Cargo\\.toml$", LicenseLocation::Crate),
456 ("(^|/)Dockerfile", LicenseLocation::Tag(LicenseTagStyle::shell_comment_style())),
457 ("(^|/)LICENSE$", LicenseLocation::NoLicense),
458 ("(^|/)LICENSE\\.QT$", LicenseLocation::NoLicense),
459 ("(^|/)README$", LicenseLocation::NoLicense),
460 ("(^|/)\\.eslintrc\\.yml$", LicenseLocation::NoLicense),
461 ("(^|/)memory\\.x$", LicenseLocation::NoLicense), // third-party file
462 ("(^|/)webpack\\..+\\.js$", LicenseLocation::NoLicense),
463 ("(^|/)partitions\\.csv$", LicenseLocation::NoLicense),
464 ("(^|/)sdkconfig", LicenseLocation::NoLicense), // auto-generated
465 ("(^|/)Pipfile$", LicenseLocation::Tag(LicenseTagStyle::shell_comment_style())),
466 ("(^|/)\\.npmrc$", LicenseLocation::Tag(LicenseTagStyle::shell_comment_style())),
467 ("(^|/)pnpm-lock\\.yaml$", LicenseLocation::NoLicense),
468 ("(^|/)biome\\.json$", LicenseLocation::NoLicense),
469 ("(^|/)package-lock\\.json$", LicenseLocation::NoLicense),
470 ("(^|/)py.typed$", LicenseLocation::Tag(LicenseTagStyle::shell_comment_style())),
471
472 // Path prefix matches:
473 ("^editors/tree-sitter-slint/corpus/", LicenseLocation::NoLicense), // liberal license
474 ("^api/cpp/docs/_static/", LicenseLocation::NoLicense),
475 ("^api/cpp/docs/_templates/", LicenseLocation::NoLicense),
476 ("^docs/quickstart/theme/", LicenseLocation::NoLicense),
477 ("^editors/tree-sitter-slint/queries/", LicenseLocation::NoLicense), // liberal license
478
479 // directory based matches
480 ("(^|/)LICENSES/", LicenseLocation::NoLicense),
481
482 // Extension matches:
483 ("\\.60$", LicenseLocation::Tag(LicenseTagStyle::c_style_comment_style())),
484 ("\\.60\\.disabled$", LicenseLocation::Tag(LicenseTagStyle::c_style_comment_style())),
485 ("\\.astro$", LicenseLocation::Tag(LicenseTagStyle::c_style_comment_style())),
486 ("\\.cmake$", LicenseLocation::Tag(LicenseTagStyle::shell_comment_style())),
487 ("\\.cmake.in$", LicenseLocation::Tag(LicenseTagStyle::shell_comment_style())),
488 ("\\.conf$", LicenseLocation::Tag(LicenseTagStyle::shell_comment_style())),
489 ("\\.cpp$", LicenseLocation::Tag(LicenseTagStyle::c_style_comment_style())),
490 ("\\.css$", LicenseLocation::NoLicense),
491 ("\\.gitattributes$", LicenseLocation::NoLicense),
492 ("\\.gitignore$", LicenseLocation::NoLicense),
493 ("\\.vscodeignore$", LicenseLocation::NoLicense),
494 ("\\.dockerignore$", LicenseLocation::NoLicense),
495 ("\\.dockerignore$", LicenseLocation::NoLicense),
496 ("\\.prettierignore$", LicenseLocation::NoLicense),
497 ("\\.bazelignore$", LicenseLocation::NoLicense),
498 ("\\.npmignore$", LicenseLocation::NoLicense),
499 ("\\.h$", LicenseLocation::Tag(LicenseTagStyle::c_style_comment_style())),
500 ("\\.html$", LicenseLocation::NoLicense),
501 ("\\.java$", LicenseLocation::Tag(LicenseTagStyle::c_style_comment_style())),
502 ("\\.jpg$", LicenseLocation::NoLicense),
503 ("\\.js$", LicenseLocation::Tag(LicenseTagStyle::c_style_comment_style())),
504 ("\\.json$", LicenseLocation::NoLicense),
505 ("\\.jsonc$", LicenseLocation::NoLicense),
506 ("\\.license$", LicenseLocation::NoLicense),
507 ("\\.md$", LicenseLocation::Tag(LicenseTagStyle::html_comment_style())),
508 ("\\.mdx$", LicenseLocation::Tag(LicenseTagStyle::html_comment_style())),
509 ("\\.mjs$", LicenseLocation::Tag(LicenseTagStyle::c_style_comment_style())),
510 ("\\.mts$", LicenseLocation::Tag(LicenseTagStyle::c_style_comment_style())),
511 ("\\.hbs$", LicenseLocation::Tag(LicenseTagStyle::html_comment_style())),
512 ("\\.overlay$", LicenseLocation::Tag(LicenseTagStyle::c_style_comment_style())),
513 ("\\.pdf$", LicenseLocation::NoLicense),
514 ("\\.png$", LicenseLocation::NoLicense),
515 ("\\.mo$", LicenseLocation::NoLicense),
516 ("\\.po$", LicenseLocation::Tag(LicenseTagStyle::shell_comment_style())),
517 ("\\.pot$", LicenseLocation::Tag(LicenseTagStyle::shell_comment_style())),
518 ("\\.rs$", LicenseLocation::Tag(LicenseTagStyle::c_style_comment_style())),
519 ("\\.rst$", LicenseLocation::Tag(LicenseTagStyle::rst_comment_style())),
520 ("\\.sh$", LicenseLocation::Tag(LicenseTagStyle::shell_comment_style())),
521 ("\\.slint$", LicenseLocation::Tag(LicenseTagStyle::c_style_comment_style())),
522 ("\\.slint\\.disabled$", LicenseLocation::Tag(LicenseTagStyle::c_style_comment_style())),
523 ("\\.sublime-commands$", LicenseLocation::NoLicense),
524 ("\\.sublime-settings$", LicenseLocation::NoLicense),
525 ("\\.sublime-syntax$", LicenseLocation::Tag(LicenseTagStyle::shell_comment_style())),
526 ("\\.svg$", LicenseLocation::NoLicense),
527 ("\\.tmPreferences$", LicenseLocation::NoLicense),
528 ("\\.toml$", LicenseLocation::NoLicense),
529 ("\\.ts$", LicenseLocation::Tag(LicenseTagStyle::c_style_comment_style())),
530 ("\\.tsx$", LicenseLocation::Tag(LicenseTagStyle::c_style_comment_style())),
531 ("\\.ttf$", LicenseLocation::NoLicense),
532 ("\\.txt$", LicenseLocation::NoLicense),
533 ("\\.ui$", LicenseLocation::NoLicense),
534 ("\\.webp$", LicenseLocation::NoLicense),
535 ("\\.xml$", LicenseLocation::NoLicense),
536 ("\\.yaml$", LicenseLocation::Tag(LicenseTagStyle::shell_comment_style())),
537 ("\\.yml$", LicenseLocation::Tag(LicenseTagStyle::shell_comment_style())),
538 ("\\.py$", LicenseLocation::Tag(LicenseTagStyle::shell_comment_style())),
539 ("\\.pyi$", LicenseLocation::Tag(LicenseTagStyle::shell_comment_style())),
540 ("\\.proto$", LicenseLocation::Tag(LicenseTagStyle::c_style_comment_style())),
541 ("\\.bazelrc$", LicenseLocation::Tag(LicenseTagStyle::shell_comment_style())),
542 ("MODULE.bazel$", LicenseLocation::Tag(LicenseTagStyle::shell_comment_style())),
543 ("BUILD.bazel$", LicenseLocation::Tag(LicenseTagStyle::shell_comment_style())),
544 ("MODULE.bazel.lock$", LicenseLocation::NoLicense),
545 ("\\.patch$", LicenseLocation::Tag(LicenseTagStyle::shell_comment_style())), // Doesn't really need a # prefix, but better than nothing
546 ("\\.bazelversion$", LicenseLocation::NoLicense),
547 ]
548 .iter()
549 .map(|(re, ty)| (regex::Regex::new(re).unwrap(), *ty))
550 .collect();
551}
552
553lazy_static! {
554 static ref LICENSE_FOR_FILE: Vec<(regex::Regex, &'static str)> = [
555 ("^editors/tree-sitter-slint/grammar.js$", MIT_LICENSE),
556 ("^helper_crates/const-field-offset/", MIT_OR_APACHE2_LICENSE),
557 ("^helper_crates/vtable/", MIT_OR_APACHE2_LICENSE),
558 ("^api/cpp/esp-idf/LICENSE$", TRIPLE_LICENSE),
559 ("^examples/", MIT_LICENSE),
560 ("^demos/", MIT_LICENSE),
561 ("^docs/", MIT_LICENSE),
562 ("^api/cpp/docs/", MIT_LICENSE),
563 ("(^|/)(README|CONTRIBUTING|CHANGELOG|LICENSE)\\.md", TRIPLE_LICENSE),
564 (".*\\.md$", MIT_LICENSE),
565 (".*", TRIPLE_LICENSE),
566 ]
567 .iter()
568 .map(|(re, ty)| (regex::Regex::new(re).unwrap(), *ty))
569 .collect();
570 // cspell:enable
571}
572
573const TRIPLE_LICENSE: &str =
574 "GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0";
575const MIT_LICENSE: &str = "MIT";
576const MIT_OR_APACHE2_LICENSE: &str = "MIT OR Apache-2.0";
577
578// This is really just the SPDX expression after the copyright line. The existence of the
579// Copyright prefix is enforced by the tag scanning (tag_start).
580pub struct LicenseHeader<'a>(&'a str);
581
582impl LicenseHeader<'_> {
583 fn to_string(&self, style: &LicenseTagStyle, license: &str) -> String {
584 let mut result: String = [style.line_prefix, style.line_indentation, self.0].concat();
585
586 if self.0 == SPDX_LICENSE_LINE {
587 result.push_str(string:license);
588 }
589
590 result
591 }
592}
593
594#[cfg(test)]
595const EXPECTED_SPDX_EXPRESSION: &str =
596 "GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0";
597
598const SPDX_LICENSE_ID: &str = const_format::concatcp!("SP", "DX-License-Identifier:"); // Do not confuse the reuse tool
599const SPDX_LICENSE_LINE: &str = const_format::concatcp!(SPDX_LICENSE_ID, " "); // Do not confuse the reuse tool
600
601const EXPECTED_HEADER: LicenseHeader<'static> = LicenseHeader(SPDX_LICENSE_LINE);
602
603const EXPECTED_HOMEPAGE: &str = "https://slint.dev";
604const ALLOWED_HOMEPAGE: &str = "https://slint.rs";
605const EXPECTED_REPOSITORY: &str = "https://github.com/slint-ui/slint";
606
607fn collect_files() -> Result<Vec<PathBuf>> {
608 let root = super::root_dir();
609
610 let mut files = Vec::new();
611 let (ls_files_output, split_char) = if root.join(".jj").exists() {
612 (
613 super::run_command(
614 "jj",
615 &["file", "list"],
616 std::iter::empty::<(std::ffi::OsString, std::ffi::OsString)>(),
617 )?
618 .stdout,
619 b'\n',
620 )
621 } else {
622 (
623 super::run_command(
624 "git",
625 &["ls-files", "-z"],
626 std::iter::empty::<(std::ffi::OsString, std::ffi::OsString)>(),
627 )?
628 .stdout,
629 b'\0',
630 )
631 };
632
633 for path in ls_files_output.split(|ch| *ch == split_char) {
634 if path.is_empty() {
635 continue;
636 }
637 let path = PathBuf::from_str(
638 std::str::from_utf8(path)
639 .context("Error decoding file list command output from VCS as utf-8")?,
640 )
641 .context("Failed to decide path output in VCS file list")?;
642
643 if !path.is_dir() {
644 files.push(root.join(path));
645 }
646 }
647
648 Ok(files)
649}
650
651#[derive(Debug)]
652enum CargoDependency {
653 Workspace,
654 Full { path: String, version: String },
655}
656
657impl CargoDependency {
658 fn new(encoded_value: &toml_edit::Value) -> Option<Self> {
659 match encoded_value {
660 toml_edit::Value::String(s) => {
661 Some(Self::Full { version: s.value().clone(), path: String::new() })
662 }
663 toml_edit::Value::Float(_) => None,
664 toml_edit::Value::Datetime(_) => None,
665 toml_edit::Value::Boolean(_) => None,
666 toml_edit::Value::Array(_) => None,
667 toml_edit::Value::Integer(_) => None,
668 toml_edit::Value::InlineTable(table) => {
669 if table.get("workspace").is_some() {
670 Some(Self::Workspace)
671 } else {
672 Some(Self::Full {
673 path: table.get("path").and_then(|x| x.as_str()).unwrap_or("").to_owned(),
674 version: table
675 .get("version")
676 .and_then(|x| x.as_str())
677 .unwrap_or("")
678 .to_owned(),
679 })
680 }
681 }
682 }
683 }
684}
685
686struct CargoToml {
687 path: std::path::PathBuf,
688 doc: toml_edit::DocumentMut,
689 edited: bool,
690}
691
692impl CargoToml {
693 fn new(path: &Path) -> Result<Self> {
694 let source = &std::fs::read_to_string(path).context("Error reading file")?;
695 Ok(Self { doc: source.parse()?, edited: false, path: path.to_path_buf() })
696 }
697
698 fn is_workspace(&self) -> bool {
699 self.doc.as_table().get("workspace").is_some()
700 }
701
702 fn workspace_version(&self) -> Result<&str> {
703 if !self.is_workspace() {
704 Err(anyhow!("Not a workspace, can not extract version info."))
705 } else {
706 Ok(self
707 .doc
708 .as_table()
709 .get("workspace")
710 .unwrap()
711 .get("package")
712 .unwrap()
713 .get("version")
714 .unwrap()
715 .as_str()
716 .unwrap())
717 }
718 }
719
720 fn package(&self) -> Result<&toml_edit::Table> {
721 if self.is_workspace() {
722 self.doc
723 .as_table()
724 .get("workspace")
725 .and_then(|w| w.as_table())
726 .and_then(|w| w.get("package"))
727 .and_then(|p| p.as_table())
728 .ok_or_else(|| anyhow!("Invalid Cargo.toml -- cannot find workspace package section in workspace file"))
729 } else {
730 self.doc
731 .as_table()
732 .get("package")
733 .and_then(|p| p.as_table())
734 .ok_or_else(|| anyhow!("Invalid Cargo.toml -- cannot find package section"))
735 }
736 }
737
738 fn dependencies(&self, dep_type: &str) -> Vec<(String, CargoDependency)> {
739 let table = if self.is_workspace() {
740 self.doc
741 .as_table()
742 .get("workspace")
743 .and_then(|w| w.as_table())
744 .and_then(|w| w.get(dep_type))
745 .and_then(|d| d.as_table())
746 } else {
747 self.doc.as_table().get(dep_type).and_then(|d| d.as_table())
748 };
749 match table {
750 Some(dep_table) => dep_table
751 .iter()
752 .filter_map(|(name, entry)| {
753 entry.as_value().and_then(|entry| {
754 CargoDependency::new(entry).map(|entry| (name.to_owned(), entry))
755 })
756 })
757 .collect(),
758 None => Vec::new(),
759 }
760 }
761
762 fn check_and_fix_package_string_field<'a>(
763 &mut self,
764 fix_it: bool,
765 field: &'a str,
766 expected_str: &'a str,
767 ) -> Result<()> {
768 let is_workspace = self.is_workspace();
769 match self.package()?.get(field) {
770 Some(field_value) => {
771 match field_value.get("workspace").and_then(|v| v.as_bool()) {
772 Some(true) if is_workspace => {
773 return Err(anyhow!(
774 "Using workspace {}.workspace = true in workspace",
775 field
776 ))
777 }
778 Some(true) => { /* nothing to do */ }
779 Some(false) => {
780 if fix_it {
781 eprintln!(
782 "Fixing up {:?} as instructed. It has {field}.workspace = false",
783 self.path
784 );
785 self.doc["package"][field]["workspace"] = toml_edit::value(true);
786 self.edited = true;
787 } else {
788 return Err(anyhow!(
789 "Incorrect {}.workspace found: expected true, found false",
790 field,
791 ));
792 }
793 }
794 None => match field_value.as_str() {
795 Some(text) => {
796 if text != expected_str {
797 if fix_it {
798 eprintln!("Fixing up {:?} as instructed. It has unexpected data in {field}.", self.path);
799 self.doc["package"][field] = toml_edit::value(expected_str);
800 self.edited = true;
801 } else {
802 return Err(anyhow!(
803 "Incorrect {}. Found {} expected {}",
804 field,
805 text,
806 expected_str
807 ));
808 }
809 }
810 }
811 None => return Err(anyhow!("{} field is not a string", field)),
812 },
813 };
814 }
815 None => {
816 if fix_it {
817 eprintln!("Fixing up {:?} as instructed. It has no {field}.", self.path);
818 self.doc["package"][field] = toml_edit::value(expected_str);
819 self.edited = true;
820 } else {
821 return Err(anyhow!("Missing {} field", field));
822 }
823 }
824 };
825 Ok(())
826 }
827
828 fn published(&self) -> Result<bool> {
829 Ok(self.package()?.get("publish").map(|v| v.as_bool().unwrap()).unwrap_or(true))
830 }
831
832 fn save_if_changed(&self) -> Result<()> {
833 if self.edited {
834 std::fs::write(&self.path, self.doc.to_string()).context("Error writing new Cargo.toml")
835 } else {
836 Ok(())
837 }
838 }
839}
840
841#[derive(Debug, clap::Parser)]
842pub struct LicenseHeaderCheck {
843 #[arg(long, action)]
844 fix_it: bool,
845
846 #[arg(long, action)]
847 show_all: bool,
848
849 #[arg(long, action)]
850 verbose: bool,
851
852 #[arg(skip)]
853 workspace_version: RefCell<String>,
854}
855
856impl LicenseHeaderCheck {
857 pub fn check_license_headers(&self) -> Result<(), Box<dyn std::error::Error>> {
858 let mut seen_errors = false;
859 for path in &collect_files()? {
860 let result = self
861 .check_file(path.as_path())
862 .with_context(|| format!("checking {}", &path.to_string_lossy()));
863
864 if result.is_err() {
865 seen_errors = true;
866 if self.show_all {
867 eprintln!("Error: {result:?}");
868 } else {
869 return result.map_err(|e| e.into());
870 }
871 }
872 }
873 if seen_errors {
874 Err(anyhow!("Encountered one or multiple errors. See above for details.").into())
875 } else {
876 println!("All files are ok.");
877 Ok(())
878 }
879 }
880
881 fn check_file_tags(&self, path: &Path, style: &LicenseTagStyle, license: &str) -> Result<()> {
882 let source = &std::fs::read_to_string(path).context("Error reading file")?;
883
884 let source = SourceFileWithTags::new(source, style);
885
886 if !source.has_tag() {
887 if self.fix_it {
888 eprintln!("Fixing up {path:?} as instructed. It's missing a license header.");
889 let source = source.replace_tag(&EXPECTED_HEADER, license);
890 std::fs::write(path, source).context("Error writing source")
891 } else {
892 Err(anyhow!("Missing tag"))
893 }
894 } else if source.has_license_header(&EXPECTED_HEADER) {
895 Ok(())
896 } else if self.fix_it {
897 eprintln!("Fixing up {path:?} as instructed. It has a wrong license header.");
898 let source = source.replace_tag(&EXPECTED_HEADER, license);
899 std::fs::write(path, source).context("Error writing new source")
900 } else {
901 Err(anyhow!(
902 "unexpected header.\nexpected: {}\nfound: {}",
903 EXPECTED_HEADER.to_string(style, license),
904 source.found_tag()
905 ))
906 }
907 }
908
909 fn check_cargo_toml(&self, path: &Path, license: &str) -> Result<()> {
910 let mut doc = CargoToml::new(path)?;
911
912 if doc.is_workspace() {
913 let mut wv = self.workspace_version.borrow_mut();
914 if &*wv == "" {
915 *wv = doc.workspace_version()?.to_string();
916 }
917 let expected_version = wv.clone();
918
919 // Check the workspace.dependencies:
920 self.check_dependencies(&doc, &expected_version)?;
921
922 return Ok(());
923 }
924
925 doc.check_and_fix_package_string_field(self.fix_it, "license", license)?;
926
927 if !doc.published()? {
928 // Skip further tests for package that are not published
929 return doc.save_if_changed();
930 }
931
932 if doc.check_and_fix_package_string_field(false, "homepage", ALLOWED_HOMEPAGE).is_err() {
933 doc.check_and_fix_package_string_field(self.fix_it, "homepage", EXPECTED_HOMEPAGE)?;
934 }
935 doc.check_and_fix_package_string_field(self.fix_it, "repository", EXPECTED_REPOSITORY)?;
936
937 if doc.package()?["description"].is_none() {
938 return Err(anyhow!("Missing description field"));
939 }
940
941 // Check that version of slint- dependencies are matching this version
942 let expected_version = {
943 let wv = self.workspace_version.borrow().clone();
944 doc.package()?
945 .get("version")
946 .and_then(|v| v.as_value())
947 .and_then(|v| v.as_str())
948 .unwrap_or(&wv)
949 .to_string()
950 };
951
952 self.check_dependencies(&doc, &expected_version)?;
953
954 doc.save_if_changed()
955 }
956
957 fn check_dependencies(&self, doc: &CargoToml, expected_version: &str) -> Result<()> {
958 let expected_version = format!("={expected_version}");
959
960 for (dep_name, dep) in doc
961 .dependencies("dependencies")
962 .iter()
963 .chain(doc.dependencies("build-dependencies").iter())
964 {
965 if dep_name.starts_with("slint") || dep_name.starts_with("i-slint") {
966 match dep {
967 CargoDependency::Workspace => (),
968 CargoDependency::Full { path, version } => {
969 if path.is_empty() {
970 return Err(anyhow!(
971 "slint package '{}' outside of the repository?",
972 dep_name
973 ));
974 }
975 if version != &expected_version {
976 return Err(anyhow!(
977 "Version \"{}\" must be specified for dependency {}",
978 expected_version,
979 dep_name
980 ));
981 }
982 }
983 }
984 }
985 }
986
987 for (dep_name, dep) in doc.dependencies("dev-dependencies").iter() {
988 if dep_name.starts_with("slint") || dep_name.starts_with("i-slint") {
989 match dep {
990 CargoDependency::Workspace => {
991 return Err(anyhow!(
992 "dev-dependencies cannot be from the workspace because version must be empty {dep_name}"
993 ));
994 }
995 CargoDependency::Full { path, version } => {
996 if path.is_empty() {
997 return Err(anyhow!(
998 "slint package '{}' outside of the repository?",
999 dep_name
1000 ));
1001 }
1002 if !version.is_empty() {
1003 return Err(anyhow!(
1004 "dev-dependencies version must be empty for dependency {dep_name}"
1005 ));
1006 }
1007 }
1008 }
1009 }
1010 }
1011 Ok(())
1012 }
1013
1014 fn check_file(&self, path: &Path) -> Result<()> {
1015 let repo_relative_path = path.strip_prefix(super::root_dir())?;
1016 let path_str = repo_relative_path.to_str().unwrap();
1017 let location = LICENSE_LOCATION_FOR_FILE
1018 .iter()
1019 .find_map(|(regex, style)| if regex.is_match(path_str) { Some(style) } else { None })
1020 .with_context(|| "Cannot determine the expected license header style. Please fix the license checking xtask.")?;
1021
1022 let license = LICENSE_FOR_FILE
1023 .iter()
1024 .find_map(
1025 |(regex, license)| if regex.is_match(path_str) { Some(license) } else { None },
1026 )
1027 .with_context(|| {
1028 "Cannot determine the expected license. Please fix the license checking xtask."
1029 })?;
1030
1031 match location {
1032 LicenseLocation::Tag(tag_style) => self.check_file_tags(path, tag_style, license),
1033 LicenseLocation::Crate => {
1034 self.check_file_tags(path, &LicenseTagStyle::shell_comment_style(), license)?;
1035 self.check_cargo_toml(path, license)
1036 }
1037 LicenseLocation::NoLicense => {
1038 if self.verbose {
1039 println!("Skipping {path_str} as configured");
1040 }
1041 Ok(())
1042 }
1043 }
1044 }
1045}
1046