Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 6 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "lisensor"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
description = "Tool to automatically add, check, and fix license notices in the source files"
license = "MIT"
Expand All @@ -22,9 +22,13 @@ serde = "1"
[dependencies.cu]
package = "pistonite-cu"
version = "0.4.0"
features = ["cli", "fs", "coroutine-heavy", "toml"]
features = ["print", "fs", "coroutine-heavy", "toml"]
# path = "../cu/packages/copper"

[features]
default = ["cli"]
cli = ["cu/cli"]

[package.metadata.binstall.signing]
algorithm = "minisign"
pubkey = "RWThJQKJaXayoZBe0YV5LV4KFkQwcqQ6Fg9dJBz18JnpHGdf/cHUyKs+"
Expand Down
3 changes: 2 additions & 1 deletion Lisensor.toml
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
[Pistonite]
"src/**/*.rs" = "MIT"
"src/**/*" = "MIT"
"tests/**/*.rs" = "MIT"
125 changes: 115 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@
![License Badge](https://img.shields.io/github/license/Pistonite/lisensor)
![Issue Badge](https://img.shields.io/github/issues/Pistonite/lisensor)

```
# install from source
cargo install lisensor
# install prebuilt binary with cargo-binstall
cargo binstall lisensor
```

Lisensor (pronounced *licenser*) is a tool that automatically adds a
license notice to your source file, like this:

Expand All @@ -19,6 +26,8 @@ For languages such as python, the comment style will automatically
be changed to `#` instead of `//`. Languages that do not have
either of the comment styles are currently not supported. (Feel free to PR).

## Usage

The CLI usage is:
```
lisensor [CONFIG] ... [-f]
Expand All @@ -27,12 +36,16 @@ lisensor [CONFIG] ... [-f]
`CONFIG` is one of more config file for Lisensor (see below). `-f` will attempt to automatically
fix the files in place.

The crate also has a library target of the same name. It's intended to be
used by tests in the project, but might be helpful for integrating `lisensor` into your own tooling.

## Config
By default, `lisensor` looks for `Lisensor.toml` then `lisensor.toml`
in the current directory if no config files are specified.
Paths in the config file are relative to the directory containing
the config file, meaning running `lisensor` from anywhere will resulting
in the same outcome.

Globs in the config file are relative to the directory containing
the config file, meaning running `lisensor` from anywhere will result
in the same outcome. Currently, symlinks are not followed.

The config file should contain one table per copyright holder.
The table should contain key-value pairs, where the keys are
Expand All @@ -42,7 +55,7 @@ and the value is any SPDX ID, but the value is not validated.
For example:

```toml
[Pistonite]
["Foobar contributors"]
"**/*.rs" = "MIT"
```

Expand Down Expand Up @@ -76,10 +89,102 @@ if a file is covered by conflicting configs. This prevents "fake" successful
fixes. However, the fix mode might still edit the file according to one
of the configs specified (arbitrarily chosen) before reporting the error.

## Line Ending
When checking, any line ending is accepted. When fixing, it will turn the file
into UNIX line ending.
## Compatibility with Other License Notices
It's common if some file is taken from another project, you must include
a license notice if it's not already in the file. In this case,
it's recommended to use a sentinel line (see Format Behavior Details below)
to prevent the tool from accidentally overriding the original notice.

```
// This file was taken from Foobar project, see original license notices below
//
// SPDX-License-Identifier: MIT
// Copyright (c) 2025 Other people
//
// ^ This is bad because running the tool will now
// change the holder and license into you, even though what you want
// is to add another notice on top of it
```

Running the tool will change it to:
```
// SPDX-License-Identifier: Your License
// Copyright (c) 2025 You

// This file was taken from Foobar project, see original license notices below
//
//
// ^ This is bad because running the tool will now
// change the holder and license into you, even though what you want
// is to add another notice on top of it
```

Adding a sentinel line will fix this:
```
// * * * * *
// This file was taken from Foobar project, see original license notices below
//
// SPDX-License-Identifier: MIT
// Copyright (c) 2025 Other people
//
// ^ Now the tool will ignore everything after * * * * *
```

Now a new notice will be added
```
// SPDX-License-Identifier: Your License
// Copyright (c) 2025 You
// * * * * *
// This file was taken from Foobar project, see original license notices below
//
// SPDX-License-Identifier: MIT
// Copyright (c) 2025 Other people
//
// ^ Now the tool will ignore everything after * * * * *
```

## Format Behavior Details
The tool will check and ensure the following conditions are true for a file:

1. The first line is the license line, with the comment style for that file,
and starting with `SPDX-License-Identifier: ` after the comment, followed
by the expected license identifier.
2. The second line is the copy right line, with the comment style for that file,
and starting with `Copyright (c) `, follow by a year range, where the start
can be anything and the end must be the current year at the local time the tool
is ran. If the start and end are the same year, then a single year is sufficient.
The year range is followed by a space, then the copyright holder.
3. No other line(s) exist that matches the same `SPDX-License-Identifier`
or `Copyright (c)` format for license and copyright lines, respectively.

The tool will not attempt fixing the file, if any copyright line is found
with the wrong holder. This ensures that the tool never accidentally override
license notices from the original source file.

If a source file contains license notice(s) from its original authors,
you must specify a *sentinel* line after your license notice. The tool
will skip checking all contents after the sentinel line. The sentinel line
is the same comment style followed by `* * * * *` (five `*` separated by four spaces).
Anything can follow after that. For example:

```
// SPDX-License-Identifier: MIT
// Copyright (c) 2024-2025 Foobar contributors
// * * * * * other license information below
// SPDX-License-Identifier: MIT
// Copyright (c) 2017-2018 Bizbaz contributors
```

When the year becomes `2026`, the tool will change the notice
for `Foobar contributors`, but will not touch anything below the sentinel line.

Other:

When checking, only the license and copyright lines are checked.
When fixing, it will add an empty line between the notice
and the rest of the file, if there isn't already.
- Line ending:
- When checking, any line ending is accepted.
- When fixing, it will turn the file into UNIX line ending.
- When checking, only the first 2 lines are checked, the rest of the file
is ignored.
- When fixing, if the third line is not a sentinel line or empty line,
it will ensure there's an empty line between the license notice and the
rest of the content.
11 changes: 11 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ tasks:
- rm -rf mono-dev
- git clone https://github.com/Pistonight/mono-dev --depth 1

build-lib:
- cargo build --no-default-features

check:
- task: cargo:clippy-all
- task: cargo:fmt-check
Expand All @@ -20,6 +23,9 @@ tasks:
- cargo test
- task: test-inline-cmd

test-fixt:
- cargo test --test fixture_test

test-inline-cmd:
dir: src
cmds:
Expand All @@ -29,3 +35,8 @@ tasks:
- cargo run -- -f
- task: cargo:fmt-fix
- task: check

clean:
- cargo clean
- rm -f tests/fixtures/*.txt_out
- rustup update
66 changes: 66 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2025 Pistonite

use cu::pre::*;

use crate::Config;

/// Check or fix license notices
#[derive(Debug, Clone, PartialEq, clap::Parser)]
pub struct Cli {
/// Attempt fix the license notice on the files
#[clap(short, long)]
pub fix: bool,
/// In inline config mode, specify the copyright holder
#[clap(short = 'H', long, requires("license"))]
pub holder: Option<String>,
/// In inline config mode, specify the SPDX ID for the license
#[clap(short = 'L', long, requires("holder"))]
pub license: Option<String>,

#[clap(flatten)]
pub common: cu::cli::Flags,

/// Paths to config files, or in inline config mode, glob patterns for source files
/// to apply the license notice.
pub paths: Vec<String>,
}

/// Convert the CLI args into configuration object
pub fn config_from_cli(args: &mut crate::Cli) -> cu::Result<Config> {
match (args.holder.take(), args.license.take()) {
(Some(holder), Some(license)) => {
if let Some(config_path) = crate::try_find_default_config_file() {
cu::bail!(
"--holder or --license cannot be specified when {config_path} is present in the current directory"
);
}
Ok(Config::new(
holder,
license,
std::mem::take(&mut args.paths),
))
}
// clap ensures both are None
_ => {
let mut iter = std::mem::take(&mut args.paths).into_iter();
let mut config = match iter.next() {
None => {
let Some(config_path) = crate::try_find_default_config_file() else {
cu::bail!(
"cannot find Lisensor.toml, and no config files are specified on the command line."
);
};
Config::build(config_path)?
}
Some(first) => Config::build(&first)?,
};

for path in iter {
config.absorb(Config::build(&path)?)?;
}

Ok(config)
}
}
}
68 changes: 21 additions & 47 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,64 +8,31 @@ use std::sync::Arc;

use cu::pre::*;

/// Convert the CLI args into configuration object
pub fn load_config(args: &mut crate::Cli) -> cu::Result<Config> {
match (args.holder.take(), args.license.take()) {
(Some(holder), Some(license)) => {
if let Some(config_path) = find_config_path() {
cu::bail!(
"--holder or --license cannot be specified when {config_path} is present in the current directory"
);
}
Ok(Config::inline(
holder,
license,
std::mem::take(&mut args.paths),
))
}
// clap ensures both are None
_ => {
let mut iter = std::mem::take(&mut args.paths).into_iter();
let mut config = match iter.next() {
None => {
let Some(config_path) = find_config_path() else {
cu::bail!(
"cannot find Lisensor.toml, and no config files are specified on the command line."
);
};
Config::build(config_path)?
}
Some(first) => Config::build(&first)?,
};

for path in iter {
config.absorb(Config::build(&path)?)?;
}

Ok(config)
}
}
}

fn find_config_path() -> Option<&'static str> {
/// Try finding the default config files according to the order
/// specified in the documentation (see repo README)
pub fn try_find_default_config_file() -> Option<&'static str> {
["Lisensor.toml", "lisensor.toml"]
.into_iter()
.find(|x| Path::new(x).exists())
}

/// Config object
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Config {
// glob -> (holder, license)
globs: BTreeMap<String, (Arc<String>, Arc<String>)>,
}

/// Raw config read from a toml config file.
///
/// The format is holder -> glob -> license
#[derive(Deserialize)]
struct TomlConfig(BTreeMap<String, BTreeMap<String, String>>);

/// Config object
pub struct Config {
// glob -> (holder, license)
globs: BTreeMap<String, (Arc<String>, Arc<String>)>,
}
impl Config {
pub fn inline(holder: String, license: String, glob_list: Vec<String>) -> Self {
/// Create a config object from a single holder and license,
/// with multiple glob patterns.
pub fn new(holder: String, license: String, glob_list: Vec<String>) -> Self {
let holder = Arc::new(holder);
let license = Arc::new(license);
let mut globs = BTreeMap::new();
Expand All @@ -83,7 +50,11 @@ impl Config {
}
Self { globs }
}
/// Build the config from a path, error if conflicts are detected

/// Build the config by reading the file specified, error if conflicts are detected
///
/// The globs specified in the config file are relative to the parent directory
/// of `path`.
pub fn build(path: &str) -> cu::Result<Self> {
let raw = toml::parse::<TomlConfig>(&cu::fs::read_string(path)?)?;
let parent = Path::new(path)
Expand Down Expand Up @@ -155,7 +126,10 @@ impl Config {

impl Config {
/// Iterate the resolve paths as (path, holder, license)
#[allow(clippy::should_implement_trait)]
pub fn into_iter(self) -> impl Iterator<Item = (String, Arc<String>, Arc<String>)> {
// we can't implement the IntoIterator trait because
// the map object has an unnamed function type
self.globs
.into_iter()
.map(|(path, (holder, license))| (path, holder, license))
Expand Down
Loading
Loading