diff --git a/commands/commands.go b/commands/commands.go new file mode 100644 index 0000000..684903c --- /dev/null +++ b/commands/commands.go @@ -0,0 +1,59 @@ +package commands + +import ( + "errors" + "fmt" +) + +// A Command executes a predefined procedure established before its creation. +type Command interface { + Execute() error +} + +type setup interface { + Parse(args []string) Command +} + +// Parse creates a valid Command from args. The expected format +// of args is: " ". If an error occurs and a +// valid command can not be constructed, a new command is created explaining +// the error and suggesting the "lawyer help" command. +func Parse(args []string) Command { + if len(args) == 0 { + return suggestHelp(errors.New("no subcommand specified")) + } + + setup, err := getSetup(args[0]) + if err != nil { + return suggestHelp(err) + } + + return setup.Parse(args[1:]) +} + +func getSetup(command string) (setup, error) { + switch command { + case "indict": + return setupForIndict(), nil + case "help", "--help", "-h": + return setupForHelp(), nil + default: + return nil, fmt.Errorf("unknown command \"%v\"", command) + } +} + +func suggestHelp(err error) suggestHelpCommand { + return suggestHelpCommand{ + error: err, + } +} + +type suggestHelpCommand struct { + error +} + +func (suggHelp suggestHelpCommand) Execute() error { + fmt.Println("invalid command: " + suggHelp.error.Error()) + fmt.Println("use \"lawyer help\" to show usage information") + return nil +} diff --git a/commands/commands_test.go b/commands/commands_test.go new file mode 100644 index 0000000..a8705cb --- /dev/null +++ b/commands/commands_test.go @@ -0,0 +1,29 @@ +package commands + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParsesCommands(t *testing.T) { + path := "tes.txt" + + var cases = []struct { + in []string + want Command + }{ + { + in: []string{"indict", path}, + want: &indictCommand{ + paths: []string{path}, + lawPath: "", + }, + }, + } + + for _, cas := range cases { + got := Parse(cas.in) + require.Equal(t, cas.want, got) + } +} diff --git a/commands/help_command.go b/commands/help_command.go new file mode 100644 index 0000000..24c10b6 --- /dev/null +++ b/commands/help_command.go @@ -0,0 +1,68 @@ +package commands + +import ( + "fmt" + + "./manuals" +) + +type helpCommand struct { + subject string +} + +func (help helpCommand) Execute() (err error) { + if help.subject == "" { + printSummary() + } else { + err = printManual(help.subject) + } + + return err +} + +func printSummary() { + fmt.Println("Lawyer is a tool for maintaining copyright headers in source code.") + fmt.Println() + printUsage() + fmt.Println() + printCommands() + fmt.Println() + printTopics() +} + +func printUsage() { + fmt.Print("Usage:") + fmt.Println() + fmt.Println("\tlawyer [arguments]") +} + +func printCommands() { + fmt.Println("The commands are:") + fmt.Println() + for command, manual := range manuals.CommandManualMap { + fmt.Printf("\t%-10v %v\n", command, manual.Summary) + } + fmt.Println() + fmt.Println("Use \"go help \" for more information about a command.") +} + +func printTopics() { + fmt.Println("Additional help topics:") + fmt.Println() + for topic, manual := range manuals.TopicManualMap { + fmt.Printf("\t%-10v %v\n", topic, manual.Summary) + } + fmt.Println() + fmt.Println("Use \"go help \" for more information about that topic.") +} + +func printManual(command string) error { + foundManual := manuals.EntireManualMap[command] + + if foundManual == *new(manuals.Manual) { + return fmt.Errorf("no manual found for \"%v\"", command) + } + + fmt.Println(foundManual.Content) + return nil +} diff --git a/commands/help_setup.go b/commands/help_setup.go new file mode 100644 index 0000000..aa236cb --- /dev/null +++ b/commands/help_setup.go @@ -0,0 +1,28 @@ +package commands + +import ( + "errors" + "flag" +) + +type helpSetup struct { + flags *flag.FlagSet + print bool +} + +func setupForHelp() setup { + return new(helpSetup) +} + +func (setup helpSetup) Parse(args []string) Command { + switch len(args) { + case 0: + return new(helpCommand) + case 1: + return helpCommand{ + subject: args[0], + } + default: + return suggestHelp(errors.New("too many arguments")) + } +} diff --git a/commands/indict_command.go b/commands/indict_command.go new file mode 100644 index 0000000..2ccdd9f --- /dev/null +++ b/commands/indict_command.go @@ -0,0 +1,54 @@ +package commands + +import ( + "fmt" + "os" + "regexp" + + "../laws" + "../trial" + "github.com/TwinProduction/go-color" +) + +type indictCommand struct { + paths []string + lawPath string +} + +func (indictment *indictCommand) Execute() error { + law, err := laws.RetrieveFrom(indictment.lawPath) + if err != nil { + return fmt.Errorf("unable to retrieve law contents\n%w", err) + } + + for _, path := range indictment.paths { + err := indict(path, law.Expected) + + if err != nil { + fmt.Print(color.Yellow) + fmt.Printf("%v dismissed\n", path) + fmt.Println(err.Error()) + fmt.Print(color.Reset) + } + } + + fmt.Println("indictment complete") + return nil +} + +func indict(path string, expected regexp.Regexp) error { + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + + innocent, evidence := trial.Conduct(expected, file) + fmt.Printf("%v is %v\n", path, trial.ToVerdict(innocent)) + + if !innocent { + fmt.Println("\n" + trial.FormatEvidence(evidence)) + } + + return nil +} diff --git a/commands/indict_setup.go b/commands/indict_setup.go new file mode 100644 index 0000000..7bc46b3 --- /dev/null +++ b/commands/indict_setup.go @@ -0,0 +1,32 @@ +package commands + +import ( + "errors" + "flag" +) + +type indictSetup struct { + flags *flag.FlagSet +} + +func setupForIndict() indictSetup { + flags := flag.NewFlagSet("indict", flag.ExitOnError) + + return indictSetup{ + flags: flags, + } +} + +func (setup indictSetup) Parse(args []string) Command { + setup.flags.Parse(args) + + paths := setup.flags.Args() + if len(paths) == 0 { + return suggestHelp( + errors.New("defendant path(s) required for indictment")) + } + + return &indictCommand{ + paths: paths, + } +} diff --git a/commands/manuals/indict.go b/commands/manuals/indict.go new file mode 100644 index 0000000..9023303 --- /dev/null +++ b/commands/manuals/indict.go @@ -0,0 +1,20 @@ +package manuals + +var indictManual Manual = Manual{ + Summary: "verify files have the expected header", + Content: `Reads the top 10 lines (the header) of every file found matching + and compares them to the regular expression of +"expected" inside the law file. Enter "lawyer help law" for more +information on the law file. + +Usage: + + lawyer indict + +Arguments: + + file(s) to indict + +If the header does not match the expected header, the header +is printed as evidence and the file is marked guilty.`, +} diff --git a/commands/manuals/law.go b/commands/manuals/law.go new file mode 100644 index 0000000..69adf7f --- /dev/null +++ b/commands/manuals/law.go @@ -0,0 +1,20 @@ +package manuals + +var lawManual Manual = Manual{ + Summary: "law file", + Content: `The "Law File" is a YAML file that describes the possible values +of a file header. Here is an example Law File: + +expected: ^// © ABC Company 1996. All rights reserved.$ + +Explanation: + + expected Regular expression of the expected file header. + +The Law File should be located in the current directory from which lawyer is +executed with the name "law" and the "yml" or "yaml" extension. + +Also note that YAML has multi-line support for file headers that require +multiple lines or even newlines after multiple lines. See the YAML specfication +for more details.`, +} diff --git a/commands/manuals/manuals.go b/commands/manuals/manuals.go new file mode 100644 index 0000000..74c9892 --- /dev/null +++ b/commands/manuals/manuals.go @@ -0,0 +1,36 @@ +package manuals + +// Manual represents the coupling of the one-line summary and detailed +// explaination of a cli command or general Lawyer topic. +type Manual struct { + Summary string + Content string +} + +// ManualMap represents a one-to-one mapping between a cli help keyword +// (ex. lawyer help "indict") and the corresponding Manual. +type ManualMap = map[string]Manual + +// CommandManualMap is a list of all cli command Manuals mapped by their +// corresponding help keywords. +var CommandManualMap = ManualMap{ + "help": Manual{"print this help page", "stop repeating yourself"}, + "indict": indictManual, +} + +// TopicManualMap is a list of all help topics mapped by their corresponding +// help keywords. +var TopicManualMap = ManualMap{ + "law": lawManual, +} + +// EntireManualMap is a list of all cli command Manuals AND help topic mapped +// by their corresponding help keywords. +var EntireManualMap = merge(CommandManualMap, TopicManualMap) + +func merge(a, b ManualMap) ManualMap { + for key, value := range a { + b[key] = value + } + return b +} diff --git a/laws/laws.go b/laws/laws.go new file mode 100644 index 0000000..062b8ff --- /dev/null +++ b/laws/laws.go @@ -0,0 +1,63 @@ +package laws + +import ( + "fmt" + "os" + "path/filepath" + "regexp" +) + +// A Law describes the expected values of a file header in regular expressions. +type Law struct { + Expected regexp.Regexp +} + +// RetrieveFrom extracts a Law from filepath. +// If filepath is empty, it attempts to find a Law file at +// the current working directory or, if not found, returns an error. +// Otherwise, an error is returned if filepath can not be opened, is of +// the wrong file type, or contains invalid data. +func RetrieveFrom(filepath string) (law Law, err error) { + if filepath == "" { + fmt.Print("searching for the law...") + filepath, err = findFile() + if err != nil { + fmt.Print("\n") + return law, err + } + fmt.Printf("found %v\n", filepath) + } + + file, err := os.Open(filepath) + defer file.Close() + if err != nil { + return law, err + } + + return retrieveLaw(file) +} + +func findFile() (string, error) { + path, _ := os.Getwd() + path = filepath.Join(path, "law.*") + matches, _ := filepath.Glob(path) + + if len(matches) == 0 { + return "", fmt.Errorf( + `unable to find any "law" file in the current directory` + "\n" + + `enter "lawyer help law" for more information`) + } + return matches[0], nil +} + +func retrieveLaw(file *os.File) (Law, error) { + ext := filepath.Ext(file.Name()) + switch ext { + case ".yml", ".yaml": + return retrieveFromYaml(file) + default: + return Law{}, fmt.Errorf( + `law file format "%v" not supported`+"\n"+ + `enter "lawyer help law" for more information`, ext) + } +} diff --git a/laws/yaml.go b/laws/yaml.go new file mode 100644 index 0000000..aae2d9f --- /dev/null +++ b/laws/yaml.go @@ -0,0 +1,29 @@ +package laws + +import ( + "io" + "regexp" + + "gopkg.in/yaml.v3" +) + +type yamlLaw struct { + Expected string +} + +func retrieveFromYaml(reader io.Reader) (Law, error) { + raw := new(yamlLaw) + decoder := yaml.NewDecoder(reader) + decoder.KnownFields(true) + + err := decoder.Decode(raw) + if err != nil { + return Law{}, err + } + + regex, err := regexp.Compile(raw.Expected) + + return Law{ + Expected: *regex, + }, err +} diff --git a/lawyer.go b/lawyer.go new file mode 100644 index 0000000..b0837ee --- /dev/null +++ b/lawyer.go @@ -0,0 +1,19 @@ +package main + +import ( + "fmt" + "os" + + "./commands" + "github.com/TwinProduction/go-color" +) + +func main() { + err := commands.Parse(os.Args[1:]).Execute() + + if err != nil { + fmt.Println(color.Ize(color.Red, "error while executing command")) + fmt.Println(color.Ize(color.Red, err.Error())) + os.Exit(1) + } +} diff --git a/trial/formatting.go b/trial/formatting.go new file mode 100644 index 0000000..cc1f790 --- /dev/null +++ b/trial/formatting.go @@ -0,0 +1,22 @@ +package trial + +import "github.com/TwinProduction/go-color" + +// ToVerdict converts isInnocent into a fancy string +// where isInnocent == true: green "innocent" and +// isInnocent == false: red "guilty". +func ToVerdict(isInnocent bool) string { + if isInnocent { + return color.Ize(color.Green, "innocent") + } + return color.Ize(color.Red, "guilty") +} + +// FormatEvidence sandwiches evidence in a multi-line string. +func FormatEvidence(evidence string) (format string) { + const lineSep = "--------------------------------------------------" + format += color.Ize(color.Cyan, "evidence\n"+lineSep+"\n") + format += color.Ize(color.Gray, evidence+"\n") + format += color.Ize(color.Cyan, lineSep+"\n") + return format +} diff --git a/trial/trial.go b/trial/trial.go new file mode 100644 index 0000000..10ac726 --- /dev/null +++ b/trial/trial.go @@ -0,0 +1,34 @@ +package trial + +import ( + "bufio" + "io" + "regexp" + "strings" +) + +// Conduct attempts to match the file header lines of reader and expected. +// Returns whether there was a valid match and the file header contents of +// reader. +func Conduct(expected regexp.Regexp, reader io.Reader) (bool, string) { + evidence := getEvidence(reader) + return expected.MatchString(evidence), evidence +} + +func getEvidence(reader io.Reader) string { + lines := getFileLines(reader, 10) + return strings.Join(lines, "\n") +} + +func getFileLines(reader io.Reader, lnCount int) []string { + scanner := bufio.NewScanner(reader) + + lines := make([]string, lnCount) + idx := 0 + for idx < lnCount && scanner.Scan() { + lines[idx] = scanner.Text() + idx++ + } + + return lines[:idx] +} diff --git a/trial/trial_test.go b/trial/trial_test.go new file mode 100644 index 0000000..e7949f2 --- /dev/null +++ b/trial/trial_test.go @@ -0,0 +1,65 @@ +package trial + +import ( + "io" + "regexp" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type mockReader struct { + mock.Mock +} + +func (reader *mockReader) Read(p []byte) (int, error) { + returns := reader.Mock.Called(p) + + n := copy(p, returns.String(0)) + return n, returns.Error(1) +} + +var cases = []struct { + law regexp.Regexp + evidence string + verdict bool +}{ + { + law: compile("test"), + evidence: "tset", + verdict: false, + }, + { + law: compile("test"), + evidence: "test", + verdict: true, + }, + { + law: compile(`^test\dtest$`), + evidence: "test0test", + verdict: true, + }, +} + +func TestCorrectVerdicts(t *testing.T) { + for _, c := range cases { + got, evidence := Conduct(c.law, getReaderThatReads(c.evidence, nil)) + require.Equal(t, c.evidence, evidence, c) + require.Equal(t, c.verdict, got, c) + } +} + +func compile(regex string) regexp.Regexp { + r, e := regexp.Compile(regex) + if e != nil { + panic(e) + } + return *r +} + +func getReaderThatReads(toRead string, err error) io.Reader { + reader := new(mockReader) + reader.Mock.On("Read", mock.AnythingOfType("[]uint8")).Return(toRead, io.EOF) + return reader +}