├── .codeclimate.yml ├── .github ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── premerge.yaml │ └── release.yaml ├── .gitignore ├── .golangci.yaml ├── .goreleaser.yml ├── .mdlrc ├── .yamllint.yaml ├── LICENSE ├── README.md ├── ci ├── lint.sh ├── release.sh └── test.sh ├── docs └── release-notes │ ├── next.md │ ├── template.md │ ├── v0.1.0.md │ ├── v0.2.0.md │ ├── v0.2.1.md │ ├── v0.3.0.md │ ├── v0.3.1.md │ ├── v0.4.0.md │ ├── v0.5.0.md │ ├── v0.6.0.md │ ├── v0.6.1.md │ ├── v0.6.2.md │ ├── v0.7.0.md │ └── v0.7.1.md ├── go.mod ├── go.sum ├── images ├── dependency-chains.dot ├── dependency-chains.jpg ├── full.dot ├── full.jpg ├── shared-dependencies.dot └── shared-dependencies.jpg ├── internal ├── analysis │ ├── analysis.go │ ├── analysis_test.go │ └── testdata │ │ ├── DeprecatedUpdate.yaml │ │ ├── MissingTimestamp.yaml │ │ ├── NoUpdates.yaml │ │ ├── OneDep.yaml │ │ ├── OneDirectOneIndirect.yaml │ │ └── TwoDeps.yaml ├── depgraph │ ├── colours.go │ ├── deps_mod.go │ ├── deps_pkg.go │ ├── graph.go │ ├── module.go │ ├── package.go │ ├── parser.go │ ├── query.go │ └── query_test.go ├── graph │ ├── graph.go │ ├── graph_test.go │ ├── node.go │ └── node_test.go ├── logger │ ├── encoder.go │ └── logger.go ├── modules │ ├── modules.go │ ├── modules_test.go │ ├── packages.go │ └── testdata │ │ ├── GoListError.yaml │ │ ├── InvalidListOutput.yaml │ │ ├── LoadError.yaml │ │ ├── LoadErrorNotFatal.yaml │ │ ├── NoDependencies.yaml │ │ ├── NoModule.yaml │ │ └── ReplacedDependency.yaml ├── parsers │ ├── style.go │ └── style_test.go ├── printer │ ├── clustering.go │ └── printer.go ├── query │ ├── grammar.go │ ├── parser.go │ ├── parser_test.go │ ├── tokenizer.go │ ├── tokenizer_test.go │ └── tokens.go ├── reveal │ ├── replacements.go │ ├── replacements_test.go │ └── testdata │ │ ├── mainModule │ │ └── go.mod │ │ ├── moduleA │ │ └── go.mod │ │ └── moduleB │ │ └── go.mod ├── testutil │ ├── log.go │ └── test_module.go └── util │ ├── exec.go │ └── fileutil.go ├── main.go ├── main_strings.go ├── main_test.go ├── mdl_style.rb ├── tools.mod └── tools.sum /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | version: "2" 4 | checks: 5 | method-complexity: 6 | config: 7 | threshold: 15 8 | return-statements: 9 | config: 10 | threshold: 5 11 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to this project 2 | 3 | This project welcomes contributions from the community. In order to help you make the best use of 4 | your time while contributing you will find some guidelines below that should clarify any doubts you 5 | might have and answers most of the usual questions that arise as part of general open-source 6 | development. 7 | 8 | ## Table of contents 9 | 10 | - [How to raise an Issue](#how-to-raise-an-issue) 11 | - [Opening a new Issue](#opening-a-new-issue) 12 | - [Do's and Don'ts](#dos-and-donts) 13 | - [Addressing an Issue](#addressing-an-issue) 14 | - [How to create a Pull Request](#how-to-create-a-pull-request) 15 | - [Opening a new Pull Request](#opening-a-new-pull-request) 16 | - [Writing up a good description](#writing-up-a-good-description) 17 | - [Summary](#summary) 18 | - [Tests](#tests) 19 | - [Code guidelines](#code-guidelines) 20 | - [Style and linters](#style-and-linters) 21 | - [Continuous integration](#continuous-integration) 22 | 23 | ## How to raise an Issue 24 | 25 | ### Opening a new Issue 26 | 27 | Whether you think you have found a bug, encountered some unexpected behaviour, a problem that this 28 | project could potentially solve or a suggestion for an improvement do not hesitate to open an 29 | [Issue](https://github.com/Helcaraxan/gomod/issues) describing it. Do keep in mind the following 30 | reminders to guarantee yourself the highest chance of getting a quick answer to your question and 31 | any related code changes. 32 | 33 | ### Do's and Don'ts 34 | 35 | Do: 36 | 37 | - Follow the checklist provided by the template when creating a new Issue. 38 | - Be polite and constructive in your questions and any follow-up discussions. 39 | - Focus on the _What_ and the _Why_ first, instead of the _How_. This generally allows for more 40 | open-minded thinking and a wider range of solutions. 41 | 42 | Don't: 43 | 44 | - Make demands. Open-source projects are generally maintained on people's free time and they will be 45 | very unlikely to help you if they feel you do not value that aspect of their work. 46 | - Tell someone in a discussion that they are wrong and you are right. Instead provide the arguments 47 | that explain why your approach has more advantages and / or less disadvantages than theirs. 48 | 49 | ### Addressing an Issue 50 | 51 | An Issue has been discussed and a way forward has been found? Now it's time to actually implement 52 | what has been agreed upon. Whether you are the person who raised the Issue first, a participant in 53 | the discussion or simply someone who wants to contribute to the project your next step will be to 54 | [open a Pull Request](#how-to-create-a-pull-request) with the necessary changes. 55 | 56 | If the changes are complex or might require multiple consequetive Pull Requests it is best to update 57 | the corresponding Issue and tell other participants that you will be taking on the work. Bonus 58 | points if you can also provide a rough estimate for when you think you will be able to deliver the 59 | work. This will help others understand what to expect and prevents two developers of working on the 60 | same thing. 61 | 62 | ## How to create a Pull Request 63 | 64 | ### Opening a new Pull Request 65 | 66 | If you have an idea for a change to the codebase take a pause before starting to write the actual 67 | change you have in mind. It is generally useful to answer a few basic questions first. This will 68 | help you decide whether to open up a PR with the suggested changes or if it might be more 69 | appropriate to [raise an issue](#how-to-raise-an-issue) instead to discuss the changes first. 70 | 71 | - What kind of change do you have in mind? 72 | - A simple bugfix. 73 | 74 | _Go and open that PR 😄 75 | 76 | - A complex bugfix. 77 | 78 | _Unless there is already a related open Issue where an agreement has been found on how to fix 79 | the bug it is best to first open an Issue (if it doesn't exist yet) or describe the fix you are 80 | suggesting._ 81 | 82 | - An improvement of an existing feature. 83 | 84 | _Does the improvement serve a niche use-case of the feature? Will the change break or modify 85 | existing use-cases of the feature? Does the improvement require significant changes to the code? 86 | Is the improved behaviour hard to test?_ 87 | 88 | _If you can answer yes to any of the above questions it is best to open an Issue first to 89 | discuss your idea._ 90 | 91 | - A new feature. 92 | 93 | _Open an Issue first to discuss your idea and check that it is compatible with other planned 94 | features and has the blessing of the project maintainer._ 95 | 96 | ### Writing up a good description 97 | 98 | Below you will find some examples on how to properly fill in the description of your Pull Request. A 99 | well-written description is often a pleasure to read and generally invites a project maintainer to 100 | provide quick and high-quality feedback. On the other hand, a poorly written or even absent PR 101 | description is less likely to get the attention of a project maintainer and **might even lead to 102 | your PR being closed without review** until its description is up to standards. 103 | 104 | #### Summary 105 | 106 | An examples of a good summary is: 107 | > Larger Go modules tend to generate huge dependency graph images that have large holes in them 108 | > without any nodes. 109 | > 110 | > Following the discussions in issue #issue-ref, this PR adds support for the clustering of nodes 111 | > that share the same predecessors in the dependency graph which leads to significantly smaller 112 | > image files containing less holes. The clustering is implemented via a new `depCluster` type 113 | > that exposes a few useful methods as well as some utility functions that compute the clusters for 114 | > a given dependency graph. 115 | > 116 | > Support for printing dependency graphs while using the new `depCluster` type will be added in a 117 | > follow-up PR. 118 | 119 | This description briefly describes the nature of the change and why it is beneficial. It also 120 | appropriately references the issue where the change was discussed up front before being implemented 121 | and acknowledges that there will be more changes required which will be part of a future PR. 122 | Alternatively you could also write something similar to: 123 | > Ensuring that the documentation of the project is appropriately maintained as the set of 124 | > available features changes and grows over time is tedious and error-prone if done manually. It is 125 | > much better to automate all the maintenance actions we can. Besides facilitating the regeneration 126 | > of the documentation when changed such automation also allows to test for any forgotten changes 127 | > by running the generation script as part of continuous integration. 128 | > 129 | > This PR creates a script that automatically generates some parts of the documentation and updates 130 | > the continuous integration test script to check that the documentation is being kept up-to-date. 131 | 132 | Here the description makes a clear case for the "why" of the suggested change. The change is small 133 | enough that there's not necessarily a need to raise an issue up front and any details can be 134 | discussed as part of the PR's review process. 135 | 136 | An examples of a bad summary would be: 137 | > Fix for #issue-ref 138 | 139 | This does not provide any context on the nature of the fix that is being implemented, nor why the 140 | suggested change is the _best_ fix for this specific issue. Another bad example would be to simply 141 | have no summary with the placeholder still left behind: 142 | > _Explain what the goal of your PR is, why it is needed and how it achieves the described goal._ 143 | 144 | #### Tests 145 | 146 | Again a good example first: 147 | > This PR is a bugfix and does not change the functionality of the code it touches. It does add a 148 | > few new testcases in the existing unit-tests to cover the edge-cases that exposed the bug in the 149 | > first place. 150 | 151 | Or otherwise: 152 | > New tests have been added for each of the methods exposed by the new `OptionParser` type. The 153 | > tests use a standard iteration over a list of testcases which together cover all expected usages 154 | > of the new type. 155 | 156 | Do not write something like: 157 | > The feature set added by this PR is too wide for unit-tests. We might want to add integration 158 | > tests at a later stage. 159 | 160 | Although this explanation does acknowledge the lack of tests it does not provide an adequate reason 161 | for not implementing the integration test framework first before implementing the proposed change. 162 | 163 | ## Code guidelines 164 | 165 | ### Style and linters 166 | 167 | This project encourages the use of the general Go styleguide as described in 168 | [Effective Go](https://golang.org/doc/effective_go.html) and on the 169 | [Go wiki](https://github.com/golang/go/wiki/CodeReviewComments). 170 | 171 | The more fine-grained codestyle points that are not covered by the general guidelines are enforced 172 | via linting and static analysis. You can find the exact details of what these points are by reading 173 | the corresponding [linter configuration](../.golangci.yaml). In order to get appropriate warnings 174 | while editing the code you can 175 | [configure your editor](https://github.com/golangci/golangci-lint/#editor-integration) to use 176 | `golangci-lint` with this project's specific configuration. 177 | 178 | ### Continuous integration 179 | 180 | The quality of this project's code is maintained by running each Pull Request through a continuous 181 | integration pipeline provided by [Travis CI](https://travis-ci.com/Helcaraxan/gomod). A passing 182 | build is required for each Pull Request before it can be merged. 183 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Report a bug encountered while using 'gomod'. 4 | labels: bug 5 | --- 6 | 7 | # Bug report 8 | 9 | ## Checklist 10 | 11 | - [ ] Your description contains all necessary context to understand and reproduce the bug you are reporting. 12 | - [ ] You have filled in the three sections below and deleted their corresponding placeholders texts. 13 | 14 | ## Description 15 | 16 | - 🐛 _Detail the bug you are reporting, not the fix you'd like to see._ 17 | 18 | ## Suggested way of addressing this issue 19 | 20 | _If you have any suggestions on:_ 21 | 22 | - 🔬 _How to fix your bug_ 23 | 24 | _here's where you describe them!_ 25 | 26 | ## Additional information 27 | 28 | _If there's further context or information necessary to reproduce your bug please provide it here._ 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest an improvement or a new feature for 'gomod'. 4 | labels: "feature request" 5 | --- 6 | 7 | # Feature request 8 | 9 | ## Checklist 10 | 11 | - [ ] The feature / improvement you are suggesting overlaps with the purpose of `gomod`: facilitating the management of Go modules and their dependencies. 12 | - [ ] You have examined various alternatives to the new feature / improvement you are suggesting and are describing the results in the description below. 13 | - [ ] You have filled in the three sections below and deleted their corresponding placeholders texts. 14 | 15 | ## Description 16 | 17 | - 💡 _Describe the problem you want this project to solve via a new feature, not the feature itself._ 18 | - 📈 _Describe the improvement you'd like to see, not how you want to implement it._ 19 | 20 | ## Implementation suggestions 21 | 22 | _If you have any suggestions on:_ 23 | 24 | - 🔮 _Which feature would solve your problem_ 25 | - 🔧 _How you would implement the improvement you are seeking_ 26 | 27 | _here's where you describe them!_ 28 | 29 | ## Additional information 30 | 31 | _If there's further context or information necessary to understand your problem or demonstrate the 32 | need for an improvement then specify those here. Things like an example project or code can 33 | sometimes go a long way in improving understanding._ 34 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Pull Request 2 | 3 | ## Checklist 4 | 5 | 8 | 9 | - [ ] Changes are lint-free. You can check this by running `./ci/lint.sh` or by opening a draft PR to trigger the continuous integration pipeline. 10 | - [ ] All tests pass. You can check this by running `./ci/test.sh` or by opening a draft PR to trigger the continuous integration pipeline. 11 | - [ ] If relevant, tests have been updated or added to ensure your changes will not be reverted or broken by future PRs. 12 | - [ ] If relevant, the project's documentation has been updated or extended. 13 | - [ ] If relevant, information has been added to the [release-notes document](../RELEASE_NOTES.md). 14 | - [ ] You have filled in the two sections below and deleted their corresponding placeholders texts. 15 | 16 | ## Summary 17 | 18 | _Explain what the goal of your PR is, why it is needed and how it achieves the described goal._ 19 | 20 | ## Tests 21 | 22 | _Describe if the changes your are making require the modification of existing tests or the addition 23 | of new tests. If you honestly think no tests are required please explain why._ 24 | -------------------------------------------------------------------------------- /.github/workflows/premerge.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Premerge 3 | 4 | on: # yamllint disable rule:truthy 5 | push: 6 | branches: 7 | - master 8 | pull_request: 9 | branches: 10 | - master 11 | 12 | jobs: 13 | lint: 14 | name: Lint 15 | runs-on: ubuntu-20.04 16 | steps: 17 | - name: Install Go 18 | uses: actions/setup-go@v1 19 | with: 20 | go-version: 1.15 21 | - name: Install Python 22 | uses: actions/setup-python@v1 23 | - name: Check out repository 24 | uses: actions/checkout@v2 25 | with: 26 | fetch-depth: 0 27 | persist-credentials: false 28 | - name: Run linters 29 | run: ./ci/lint.sh 30 | 31 | test: 32 | name: Test 33 | runs-on: ubuntu-20.04 34 | steps: 35 | - name: Install Go 36 | uses: actions/setup-go@v1 37 | with: 38 | go-version: 1.15 39 | - name: Check out repository 40 | uses: actions/checkout@v2 41 | with: 42 | persist-credentials: false 43 | - name: Test & publish code-coverage 44 | uses: paambaati/codeclimate-action@v2.6.0 45 | env: 46 | CC_TEST_REPORTER_ID: ef07ead9fa11867e3688cde45f90e10ba0fddc35793f2003bbf2140a10904e0e 47 | with: 48 | coverageCommand: ./ci/test.sh 49 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: release 3 | 4 | on: # yamllint disable rule:truthy 5 | push: 6 | tags: 7 | - "v*" 8 | 9 | jobs: 10 | release: 11 | runs-on: ubuntu-20.04 12 | steps: 13 | - name: Install Go 14 | uses: actions/setup-go@v1 15 | with: 16 | go-version: 1.15 17 | - name: Checkout 18 | uses: actions/checkout@v2 19 | with: 20 | fetch-depth: 0 21 | - name: Determine tag 22 | run: | 23 | TAG=${GITHUB_REF#refs/*/} 24 | echo "RELEASE_VERSION=${TAG%%-*}" >> ${GITHUB_ENV} 25 | - name: Run GoReleaser 26 | uses: goreleaser/goreleaser-action@v2 27 | with: 28 | version: latest 29 | args: release --rm-dist --release-notes=docs/release-notes/${{env.RELEASE_VERSION}}.md 30 | env: 31 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | .idea/ 3 | .vscode/ 4 | 5 | # Build and test artefacts 6 | bin/ 7 | dist/ 8 | c.out 9 | gomod-* 10 | graph.dot 11 | graph.pdf 12 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | # For full documentation of the configuration options please 4 | # see: https://github.com/golangci/golangci-lint#config-file. 5 | 6 | # options for analysis running 7 | run: 8 | # timeout for analysis, e.g. 30s, 5m, default is 1m 9 | deadline: 30s 10 | 11 | # which dirs to skip: they won't be analyzed; 12 | # can use regexp here: generated.*, regexp is applied on full path; 13 | # default value is empty list, but next dirs are always skipped independently 14 | # from this option's value: 15 | # vendor$, third_party$, testdata$, examples$, Godeps$, builtin$ 16 | skip-dirs: 17 | 18 | # which files to skip: they will be analyzed, but issues from them 19 | # won't be reported. Default value is empty list, but there is 20 | # no need to include all autogenerated files, we confidently recognize 21 | # autogenerated files. If it's not please let us know. 22 | skip-files: 23 | 24 | 25 | # output configuration options 26 | output: 27 | # colored-line-number|line-number|json|tab|checkstyle, default is "colored-line-number" 28 | format: colored-line-number 29 | 30 | 31 | # linters that we should / shouldn't run 32 | linters: 33 | disable-all: true 34 | enable: 35 | - dupl 36 | - errcheck 37 | - goconst 38 | - gocritic 39 | - gocyclo 40 | - gofmt 41 | - goimports 42 | - golint 43 | - govet 44 | - lll 45 | - misspell 46 | - nakedret 47 | - unparam 48 | - unused 49 | 50 | 51 | # all available settings of specific linters, we can set an option for 52 | # a given linter even if we deactivate that same linter at runtime 53 | linters-settings: 54 | goconst: 55 | # minimal length of string constant, 3 by default 56 | min-len: 5 57 | # minimal occurrences count to trigger, 3 by default 58 | min-occurrences: 3 59 | gocritic: 60 | # which checks should be enabled; can't be combined with 'disabled-checks'; 61 | # default are: [appendAssign assignOp caseOrder dupArg dupBranchBody dupCase flagDeref 62 | # ifElseChain regexpMust singleCaseSwitch sloppyLen switchTrue typeSwitchVar underef 63 | # unlambda unslice rangeValCopy defaultCaseOrder]; 64 | # all checks list: https://github.com/go-critic/checkers 65 | # disabled for now - hugeParam 66 | enabled-checks: 67 | - appendAssign 68 | - assignOp 69 | - boolExprSimplify 70 | - builtinShadow 71 | - captLocal 72 | - caseOrder 73 | - commentedOutImport 74 | - defaultCaseOrder 75 | - dupArg 76 | - dupBranchBody 77 | - dupCase 78 | - dupSubExpr 79 | - elseif 80 | - emptyFallthrough 81 | - ifElseChain 82 | - importShadow 83 | - indexAlloc 84 | - methodExprCall 85 | - nestingReduce 86 | - offBy1 87 | - ptrToRefParam 88 | - regexpMust 89 | - singleCaseSwitch 90 | - sloppyLen 91 | - switchTrue 92 | - typeSwitchVar 93 | - typeUnparen 94 | - underef 95 | - unlambda 96 | - unnecessaryBlock 97 | - unslice 98 | - valSwap 99 | - wrapperFunc 100 | - yodaStyleExpr 101 | gocyclo: 102 | # minimal code complexity to report, 30 by default (but we recommend 10-20) 103 | min-complexity: 15 104 | goimports: 105 | # put imports beginning with prefix after 3rd-party packages; 106 | # it's a comma-separated list of prefixes 107 | local-prefixes: github.com/Helcaraxan/gomod 108 | lll: 109 | # max line length, lines longer will be reported. Default is 120. 110 | # '\t' is counted as 1 character by default, and can be changed with the tab-width option 111 | line-length: 150 112 | maligned: 113 | # print struct with more effective memory layout or not, false by default 114 | suggest-new: true 115 | misspell: 116 | # Correct spellings using locale preferences for US or UK. 117 | # Default is to use a neutral variety of English. 118 | # Setting locale to US will correct the British spelling of 'colour' to 'color'. 119 | locale: UK 120 | nakedret: 121 | # make an issue if func has more lines of code than this setting and it has naked returns; default is 30 122 | max-func-lines: 0 # Warn on all naked returns. 123 | 124 | 125 | # rules to deal with reported isues 126 | issues: 127 | # List of regexps of issue texts to exclude, empty list by default. 128 | # But independently from this option we use default exclude patterns, 129 | # it can be disabled by `exclude-use-default: false`. To list all 130 | # excluded by default patterns execute `golangci-lint run --help` 131 | exclude: 132 | 133 | # Maximum issues count per one linter. Set to 0 to disable. Default is 50. 134 | max-per-linter: 0 135 | 136 | # Maximum count of issues with the same text. Set to 0 to disable. Default is 3. 137 | max-same-issues: 0 138 | 139 | # Show only new issues: if there are unstaged changes or untracked files, 140 | # only those changes are analyzed, else only changes in HEAD~ are analyzed. 141 | # It's a super-useful option for integration of golangci-lint into existing 142 | # large codebase. It's not practical to fix all existing issues at the moment 143 | # of integration: much better don't allow issues in new code. 144 | # Default is false. 145 | new: false 146 | 147 | # Show only new issues created after git revision `REV`. If set to "" lint the 148 | # entire specified packages. 149 | new-from-rev: "" 150 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | --- 2 | project_name: gomod 3 | before: 4 | hooks: 5 | - go mod download 6 | builds: 7 | - env: 8 | - CGO_ENABLED=0 9 | flags: 10 | - -trimpath 11 | ldflags: 12 | - -s -w 13 | - -X main.version={{.Version}} 14 | - -X main.date={{.CommitDate}} 15 | - -X main.commit={{.ShortCommit}} 16 | goos: 17 | - linux 18 | - windows 19 | - darwin 20 | goarch: 21 | - amd64 22 | - arm64 23 | goarm: 24 | - 6 25 | - 7 26 | mod_timestamp: "{{.CommitTimestamp}}" 27 | archives: 28 | - name_template: "{{.Binary}}-{{.Os}}-{{.Arch}}" 29 | format: binary 30 | release: 31 | name_template: "v{{.Version}}" 32 | github: 33 | owner: Helcaraxan 34 | name: gomod 35 | prerelease: auto 36 | -------------------------------------------------------------------------------- /.mdlrc: -------------------------------------------------------------------------------- 1 | git_recurse true 2 | style "./mdl_style.rb" -------------------------------------------------------------------------------- /.yamllint.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | rules: 3 | braces: 4 | min-spaces-inside: 0 5 | max-spaces-inside: 0 6 | min-spaces-inside-empty: -1 7 | max-spaces-inside-empty: -1 8 | brackets: 9 | min-spaces-inside: 0 10 | max-spaces-inside: 0 11 | min-spaces-inside-empty: -1 12 | max-spaces-inside-empty: -1 13 | colons: 14 | max-spaces-before: 0 15 | max-spaces-after: 1 16 | commas: 17 | max-spaces-before: 0 18 | min-spaces-after: 1 19 | max-spaces-after: 1 20 | comments: 21 | level: warning 22 | require-starting-space: true 23 | min-spaces-from-content: 1 24 | comments-indentation: 25 | level: warning 26 | document-end: disable 27 | document-start: 28 | level: warning 29 | present: true 30 | empty-lines: 31 | max: 2 32 | max-start: 0 33 | max-end: 1 34 | empty-values: 35 | forbid-in-block-mappings: false 36 | forbid-in-flow-mappings: true 37 | hyphens: 38 | max-spaces-after: 1 39 | indentation: 40 | spaces: consistent 41 | indent-sequences: true 42 | check-multi-line-strings: false 43 | key-duplicates: enable 44 | key-ordering: disable 45 | line-length: 46 | max: 150 47 | allow-non-breakable-words: true 48 | allow-non-breakable-inline-mappings: true 49 | new-line-at-end-of-file: enable 50 | new-lines: 51 | type: unix 52 | trailing-spaces: disable 53 | truthy: 54 | level: warning 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Duco van Amstel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /ci/lint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # vim: set tabstop=2 shiftwidth=2 expandtab 3 | set -u -e -o pipefail 4 | [[ -n ${DEBUG:-} ]] && set -x 5 | 6 | if [[ "$(uname -s)" != "Linux" ]]; then 7 | echo "This script is only intended to be run on Linux as the used CLI tools might not be available or differ in their semantics." 8 | exit 1 9 | fi 10 | 11 | readonly PROJECT_ROOT="$(dirname "${BASH_SOURCE[0]}")/.." 12 | cd "${PROJECT_ROOT}" 13 | 14 | # Ensure linter versions are specified or set the default values. 15 | readonly GOLANGCI_VERSION="${GOLANGCI_VERSION:-"1.27.0"}" 16 | readonly MARKDOWNLINT_VERSION="${MARKDOWNLINT_VERSION:-"0.9.0"}" 17 | readonly SHELLCHECK_VERSION="${SHELLCHECK_VERSION:-"0.7.1"}" 18 | readonly SHFMT_VERSION="${SHFMT_VERSION:-"3.1.1"}" 19 | readonly YAMLLINT_VERSION="${YAMLLINT_VERSION:-"1.23.0"}" 20 | 21 | # Retrieve linters if necessary. 22 | mkdir -p "${PWD}/bin" 23 | export PATH="${PWD}/bin:${PATH}" 24 | 25 | ## golangci-lint 26 | if [[ -z "$(command -v golangci-lint)" ]] || ! grep "${GOLANGCI_VERSION}" <<<"$(golangci-lint --version)"; then 27 | echo "Installing golangci-lint@${GOLANGCI_VERSION}." 28 | curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | BINARY="golang-ci" bash -s -- -b "${PWD}/bin" "v${GOLANGCI_VERSION}" 29 | else 30 | echo "Found installed golangci-lint@${GOLANGCI_VERSION}." 31 | fi 32 | 33 | ## shellcheck 34 | if [[ -z "$(command -v shellcheck)" ]] || ! grep "${SHELLCHECK_VERSION}" <<<"$(shellcheck --version)"; then 35 | echo "Installing shellcheck@${SHELLCHECK_VERSION}." 36 | curl -LSs "https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.linux.x86_64.tar.xz" | 37 | tar --extract --xz --strip-components=1 --directory="${PWD}/bin" "shellcheck-v${SHELLCHECK_VERSION}/shellcheck" 38 | else 39 | echo "Found installed shellcheck@${SHELLCHECK_VERSION}." 40 | fi 41 | 42 | # shfmt 43 | if [[ -z "$(command -v shfmt)" ]] || ! grep "${SHFMT_VERSION}" <<<"$(shfmt -version)"; then 44 | echo "Installing shfmt." 45 | GOBIN="${PWD}/bin" go install -modfile=tools.mod "mvdan.cc/sh/v3/cmd/shfmt" 46 | else 47 | echo "Found installed shfmt." 48 | fi 49 | 50 | ## markdownlint 51 | if [[ -z "$(command -v mdl)" ]] || ! grep "${MARKDOWNLINT_VERSION}" <<<"$(mdl --version)"; then 52 | echo "Installing mdl@${MARKDOWNLINT_VERSION}." 53 | mkdir -p "${HOME}/.ruby" 54 | export GEM_HOME="${HOME}/.ruby" 55 | gem install mdl "--version=${MARKDOWNLINT_VERSION}" --bindir=./bin 56 | else 57 | echo "Found installed mdl@${MARKDOWNLINT_VERSION}." 58 | fi 59 | 60 | ## yamllint 61 | if [[ -z "$(command -v yamllint)" ]] || ! grep "${YAMLLINT_VERSION}" <<<"$(yamllint --version)"; then 62 | echo "Installing yamllint@${YAMLLINT_VERSION}." 63 | pip install "yamllint==${YAMLLINT_VERSION}" 64 | else 65 | echo "Found installed yamllint@${YAMLLINT_VERSION}." 66 | fi 67 | 68 | # Run linters. 69 | echo "Ensuring that generated Go code is being kept up to date." 70 | go generate ./... 71 | git diff --exit-code --quiet '*.go' || ( 72 | echo "Please run 'go generate ./...' to update the generated Go code." 73 | false 74 | ) 75 | 76 | echo "Linting YAML files." 77 | yamllint --strict --config-file=./.yamllint.yaml . 78 | 79 | echo "Linting Go source code." 80 | golangci-lint run ./... 81 | 82 | echo "Ensuring that 'go.mod' and 'go.sum' are being kept up to date." 83 | go mod tidy 84 | git diff --exit-code --quiet go.mod go.sum || ( 85 | echo "Please run 'go mod tidy' to clean up the 'go.mod' and 'go.sum' files." 86 | false 87 | ) 88 | 89 | echo "Linting Bash scripts." 90 | declare -a shell_files 91 | while read -r file; do 92 | shell_files+=("${file}") 93 | done <<<"$(shfmt -f .)" 94 | shellcheck --external-sources --shell=bash --severity=style "${shell_files[@]}" 95 | 96 | shell_failure=0 97 | readonly shell_vim_directives="# vim: set tabstop=2 shiftwidth=2 expandtab" 98 | for shell_file in "${shell_files[@]}"; do 99 | if ! grep -q "^${shell_vim_directives}$" "${shell_file}"; then 100 | echo "'${shell_file}' is missing the compulsory VIm directives: ${shell_vim_directives}" 101 | shell_failure=1 102 | fi 103 | done 104 | if ((shell_failure == 1)); then 105 | echo "Errors were detected while linting shell scripts." 106 | exit 1 107 | fi 108 | 109 | echo "Checking the formatting of Bash scripts." 110 | shfmt -i 2 -s -w -d . 111 | 112 | echo "Linting Markdown files." 113 | mdl . 114 | -------------------------------------------------------------------------------- /ci/release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # vim: set tabstop=2 shiftwidth=2 expandtab 3 | set -e -u -o pipefail 4 | 5 | readonly project_root="$(dirname "${BASH_SOURCE[0]}")/.." 6 | cd "${project_root}" 7 | 8 | readonly linux_binary="gomod-linux-x86_64" 9 | readonly darwin_binary="gomod-darwin-x86_64" 10 | readonly windows_binary="gomod-windows-x86_64.exe" 11 | 12 | # Ensure we know which release version we are aiming for. 13 | if [[ -z ${RELEASE_VERSION:-} || ! ${RELEASE_VERSION} =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then 14 | echo "Please specify the targeted version via the RELEASE_VERSION environment variable (e.g. '0.5.0')." 15 | exit 1 16 | fi 17 | 18 | readonly tag="v${RELEASE_VERSION}" 19 | 20 | # Ensure we have a GitHub authentication token available. 21 | if [[ -z ${GITHUB_API_TOKEN:-} ]]; then 22 | echo "Please specify a GitHub API token with appropriate permissions via the GITHUB_API_TOKEN environment variable." 23 | exit 1 24 | fi 25 | 26 | # Ensure the release tag does not yet exist. 27 | if git tag -l | grep --quiet "^${tag}$"; then 28 | echo "The targeted releaes '${RELEASE_VERSION}' already seems to exist. Aborting." 29 | exit 1 30 | fi 31 | 32 | # Ensure we have a release-notes file for the targeted version. 33 | release_notes_file="docs/release-notes/v${RELEASE_VERSION}.md" 34 | if [[ ! -f ${release_notes_file} ]]; then 35 | echo "There is no release-notes files available for version '${RELEASE_VERSION}' in the 'docs/release-notes' folder." 36 | exit 1 37 | fi 38 | release_description="$(cat "${release_notes_file}")" 39 | 40 | readonly build_time="$(date -u +'%Y-%m-%d %H:%M:%S')" 41 | 42 | printf "\nBuilding release binaries..." 43 | printf "\n- Linux..." 44 | GOARCH=amd64 GOOS=linux go build -o "${linux_binary}" -ldflags "-X 'main.toolVersion=${tag}' -X 'main.toolDate=${build_time}'" . 45 | printf " DONE\n- MacOS..." 46 | GOARCH=amd64 GOOS=darwin go build -o "${darwin_binary}" -ldflags "-X 'main.toolVersion=${tag}' -X 'main.toolDate=${build_time}'" . 47 | printf " DONE\n- Windows..." 48 | GOARCH=amd64 GOOS=windows go build -o "${windows_binary}" -ldflags "-X 'main.toolVersion=${tag}' -X 'main.toolDate=${build_time}'" . 49 | echo " DONE" 50 | 51 | echo "--- RELEASE DESCRIPTION ---" 52 | echo "${release_description}" 53 | echo "--- RELEASE DESCRIPTION ---" 54 | echo "" 55 | echo "Are you sure you want to create the '${RELEASE_VERSION}' release with the description above?" 56 | 57 | read -r -p "(Y/n) " -n 1 58 | echo "" 59 | if [[ ! ${REPLY} =~ ^[Yy]$ ]]; then 60 | echo "Aborting." 61 | exit 1 62 | fi 63 | 64 | readonly release_notes="$(mktemp)" 65 | echo "{ 66 | \"tag_name\": \"${tag}\", 67 | \"name\": \"${tag}\", 68 | \"body\": \"$(awk '{ printf "%s\\n", $0 }' <<<"${release_description//\"/\\\"}")\" 69 | }" >"${release_notes}" 70 | 71 | printf "\nTagging and pushing release commit..." 72 | git tag --force "${tag}" 73 | git push --quiet --force origin "${tag}" 74 | echo " DONE" 75 | 76 | printf "\nCreating the GitHub release..." 77 | readonly create_response="$( 78 | curl --silent \ 79 | --data "@${release_notes}" \ 80 | --header "Authorization: token ${GITHUB_API_TOKEN}" \ 81 | --header "Content-Type: application/json" \ 82 | https://api.github.com/repos/Helcaraxan/gomod/releases 83 | )" 84 | 85 | readonly release_name="$(jq --raw-output '.name' <<<"${create_response}")" 86 | readonly release_url="$(jq --raw-output '.url' <<<"${create_response}")" 87 | readonly upload_url="$(jq --raw-output '.upload_url' <<<"${create_response}")" 88 | 89 | if [[ -z ${release_name} || -z ${release_url} || -z ${upload_url} ]]; then 90 | echo " FAILED" 91 | echo "" 92 | printf "ERROR: It appears that the release creation failed. The API's response was:\n%s\n\n" "${create_response}" 93 | exit 1 94 | fi 95 | echo " DONE" 96 | echo "The release can be found at ${release_url}." 97 | 98 | echo "" 99 | echo "Uploading release assets..." 100 | readonly release_assets=( 101 | "${linux_binary}" 102 | "${darwin_binary}" 103 | "${windows_binary}" 104 | ) 105 | for asset in "${release_assets[@]}"; do 106 | echo "- ${asset}..." 107 | upload_response="$( 108 | curl --progress-bar \ 109 | --data-binary "@${asset}" \ 110 | --header "Authorization: token ${GITHUB_API_TOKEN}" \ 111 | --header "Content-Type: application/octet-stream" \ 112 | "${upload_url%%\{*}?name=${asset}" 113 | )" 114 | upload_state="$(jq --raw-output '.state' <<<"${upload_response}")" 115 | if [[ ${upload_state} != uploaded ]]; then 116 | echo "" 117 | printf "ERROR: It appears that the upload of asset ${asset} failed. The API's response was:\n%s\n\n" "${upload_response}" 118 | exit 1 119 | fi 120 | unset upload_response 121 | unset upload_state 122 | done 123 | 124 | echo "" 125 | echo "The ${RELEASE_VERSION} release was successfully created." 126 | 127 | rm -f "${release_assets[@]}" 128 | -------------------------------------------------------------------------------- /ci/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # vim: set tabstop=2 shiftwidth=2 expandtab 3 | set -e -u -o pipefail 4 | 5 | PROJECT_ROOT="$(dirname "${BASH_SOURCE[0]}")/.." 6 | cd "${PROJECT_ROOT}" 7 | 8 | # Run all the Go tests with the race detector and generate coverage. 9 | printf "\nRunning Go test...\n" 10 | go test -v -race -coverprofile c.out -coverpkg=all ./... 11 | -------------------------------------------------------------------------------- /docs/release-notes/next.md: -------------------------------------------------------------------------------- 1 | # RELEASE-NUMBER 2 | 3 | ## High-level overview 4 | 5 | ## Bug fixes 6 | 7 | ## New features 8 | 9 | ## Breaking changes 10 | -------------------------------------------------------------------------------- /docs/release-notes/template.md: -------------------------------------------------------------------------------- 1 | # RELEASE-NUMBER 2 | 3 | ## High-level overview 4 | 5 | ## Bug fixes 6 | 7 | ## New features 8 | 9 | ## Breaking changes 10 | -------------------------------------------------------------------------------- /docs/release-notes/v0.1.0.md: -------------------------------------------------------------------------------- 1 | # v0.1.0 2 | 3 | ## High-level overview 4 | 5 | - Created CLI binary with the graph command. 6 | - Support for filtering the dependency graph on various criteria. 7 | -------------------------------------------------------------------------------- /docs/release-notes/v0.2.0.md: -------------------------------------------------------------------------------- 1 | # v0.2.0 2 | 3 | ## High-level overview 4 | 5 | - Redesigned the flags for gomod graph and their semantics for ease-of-use. 6 | - Added the gomod analyse command to generate statistics about a module's dependencies. 7 | - Numerous internal improvements. 8 | -------------------------------------------------------------------------------- /docs/release-notes/v0.2.1.md: -------------------------------------------------------------------------------- 1 | # v0.2.1 2 | 3 | ## High-level overview 4 | 5 | - Only require the dot tool when running the gomod graph command. 6 | - New developments in the project now have to go through CI before being merged. 7 | -------------------------------------------------------------------------------- /docs/release-notes/v0.3.0.md: -------------------------------------------------------------------------------- 1 | # v0.3.0 2 | 3 | ## High-level overview 4 | 5 | - Added the gomod reveal command to find and highlight hidden replaces in (indirect) module dependencies. 6 | - Added the analyze alias for gomod analyse. 7 | -------------------------------------------------------------------------------- /docs/release-notes/v0.3.1.md: -------------------------------------------------------------------------------- 1 | # v0.3.1 2 | 3 | ## High-level overview 4 | 5 | - Fixed graph printing which was broken between 0.2.1 and 0.3.0. 6 | - Fixed printing of non-versioned (local) hidden replace statements. 7 | -------------------------------------------------------------------------------- /docs/release-notes/v0.4.0.md: -------------------------------------------------------------------------------- 1 | # v0.4.0 2 | 3 | ## High-level overview 4 | 5 | - The presence of the `.dot` tool is now only required when specifying the `-V | --visual` flag to 6 | `gomod graph`. 7 | - Support for node clustering in generated `.dot` files. 8 | - More fine-grained control over graph generation via the new `--style` flag of `gomod graph`. 9 | 10 | ## New features 11 | 12 | - Generated `.dot` graphs are now using box nodes rather than the default ellipse style to reduce 13 | the size of the generated image files and improve readability. 14 | - Specifying formatting options for image generation via `gomod graph` or the underlying library 15 | functions is now done via a dedicated configuration type. 16 | - The `printer.PrintToDot` function can now generate improved layouts for dependency graphs via the 17 | use of node clustering, tightly packing modules that share common reverse dependencies together. 18 | This can result in significant improvements for larger depdendency graphs (_e.g. the PNG image of 19 | the full dependency graph for the [kubernetes](https://github.com/kubernetes/kubernetes) project 20 | has 42% less pixels and has a ~7x smaller binary size_). 21 | 22 | ## Breaking changes 23 | 24 | - The `depgraph.DepGraph` and it's associated methods have been reworked to facilitate 25 | reproducibility through determinism, meaning their signatures have changed. Both a `NodeReference` 26 | and `NodeMap` type have been introduced. 27 | - The `depgraph.GetDepGraph()` method no longer takes a boolean to indicate what output should be 28 | forwarded from the invocations of underlying tools. Instead this is inferred from the level 29 | configured on the `logrus.Logger` instance argument that it takes. `logrus.WarnLevel` and below 30 | are considered the same as `--quiet`, `logrus.DebugLevel` and above are equivalent to `--verbose`. 31 | - Output behaviour for the invocation of underlying tools has slightly changed: 32 | - By default only their `stderr` will be forwarded to the terminal output. 33 | - If the `-q | --quiet` flag is passed neither their `stderr`, not their `stdout` will be 34 | forwarded. 35 | - If the `-v | --verbose` flag is passed both `stderr` and `stdout` will be forwarded. 36 | 37 | In any case the full output of these invocations can be found in the debug logs. 38 | - The `Visual` field of the `printer.PrinterConfig` type has been replaced by `Style` which is a 39 | pointer to a nested `printer.StyleOptions` type. The `printer.Print` method will generate an 40 | image if and only if `Style` has a non-`nil` value. 41 | -------------------------------------------------------------------------------- /docs/release-notes/v0.5.0.md: -------------------------------------------------------------------------------- 1 | # v0.5.0 2 | 3 | ## High-level overview 4 | 5 | - A significant number of types, methods and functions have been renamed in preparation for a 6 | future `v1.0.0` release. These renames aim to create a more coherent interface for the 7 | functionalities exposed by the `depgraph` package. 8 | 9 | ## New features 10 | 11 | - The `depgraph.DepGraph` type now exposes a `RemoveDependency` method allowing to remove a given 12 | module including any edges starting or ending at this module. 13 | - The new `lib/modules` package exposes methods to retrieve various levels of module information. 14 | - The `depgraph.DepAnalysis` type now also contains information about the update backlog time of 15 | a module's dependencies. This reflects the timespan between the timestamp of the used version of a 16 | dependency and the timestamp of the newest available update. 17 | 18 | ## Breaking changes 19 | 20 | - Package split: the `depgraph.Module` and `depgraph.ModuleError` types have been extracted to a 21 | separate `lib/modules` package in preparation for future work that will expand the configurability 22 | of information loading to support new features. 23 | - Type renames: 24 | - `depgraph.Node` has been renamed to `depgraph.Dependency` after the pre-existing type of that 25 | name has been removed in the `v0.4.0` release. 26 | - `depgraph.NodeReference` has been renamed to `depgraph.DependencyReference`. 27 | - `depgraph.NodeMap` has been renamed to `depgraph.DependencyMap` and the associated 28 | `NewNodeMap()` function has accordingly been renamed to `NewDependencyMap()`. 29 | - The `depgraph.DepGraph` type's methods have changed: 30 | - `Main()` has been removed in favour of direct access to a field with the same name. 31 | - `Nodes()` has been removed in favour of direct access to a field named `Dependencies`. 32 | - `Node()` has been renamed to `GetDependency()`. 33 | - `AddNode()` has been renamed to `AddDependency` and now only returns a `*Dependency` instead of 34 | also a `bool`. The returned `value` is `nil` if the module passed as parameter could not be 35 | added. 36 | - The `depgraph.DependencyFilter` type's `Dependency` field has been renamed to `Module`. 37 | - The `depgraph.NewDepGraph()` function now also takes the path where the contained module lives. 38 | - The `depgraph.GetDepGraph()` function now also takes a relative or absolute path to the directory 39 | where the targeted Go module lives. 40 | -------------------------------------------------------------------------------- /docs/release-notes/v0.6.0.md: -------------------------------------------------------------------------------- 1 | # v0.6.0 2 | 3 | ## High-level overview 4 | 5 | - The generated dependency graphs now reflect the true import-based dependency paths for non-module 6 | projects instead of the artificial dependencies injected by the module system to provide 7 | reproducibility. Indirect dependencies induced by the module system are represented by dashed 8 | lines. 9 | 10 | ## New features 11 | 12 | - A new `gomod version` enables the printing of the current version of the `gomod` tool. 13 | - The `gomod graph` command has a whole new query syntax that makes it much easier to trim down the 14 | generated graph to the exact content that you need. Check the updated 15 | [README section](README.md#gomod-graph) for more details. 16 | - The `gomod graph` command now support querying the package-level import graph in addition to the 17 | already available module-level dependency graph. Simply use the new `--package | -p` flag to move 18 | into package mode. 19 | - The `--verbose` flag now takes optional string arguments to toggle debug output for specific parts 20 | of `gomod`'s logic. 21 | 22 | ## Breaking changes 23 | 24 | - All library functionalities have been, for now, moved into the `internal` tree while further work 25 | is being done on the actual API and end-functionality of `gomod`. 26 | - The `--shared` and `--dependencies` flags on the `gomod graph` command have been removed with the 27 | arrival of the new query syntax. 28 | - The `gomod completion` command has been removed with the introduction of the new query syntax 29 | which would require much more complex logic for enabling autocomplete. 30 | - `gomod` no longer wraps the invocation of `dot`. To get an image as output simply pipe `gomod`'s 31 | output into the `dot` binary on the command-line. This means that the `--format` and `--visual` 32 | flags have been removed and the the `--style` flag no longer implies `--visual`. 33 | -------------------------------------------------------------------------------- /docs/release-notes/v0.6.1.md: -------------------------------------------------------------------------------- 1 | # v0.6.1 2 | 3 | ## Bug fixes 4 | 5 | - Fixed a potential panic when using the `--annotate` flag in combination with the `--packages` flag 6 | in the `gomod graph` command. 7 | -------------------------------------------------------------------------------- /docs/release-notes/v0.6.2.md: -------------------------------------------------------------------------------- 1 | # v0.6.2 2 | 3 | ## Bug fixes 4 | 5 | - Removed panic on non-test path queries at package-level. 6 | -------------------------------------------------------------------------------- /docs/release-notes/v0.7.0.md: -------------------------------------------------------------------------------- 1 | # v0.7.0 2 | 3 | ## Bug fixes 4 | 5 | - Not all test-only dependent packages were adequately marked as such. This has been addressed and 6 | any packages only imported when building tests are now appropriately recognised as such. 7 | 8 | ## New features 9 | 10 | - If no query is specified for `gomod graph` then it will return the full dependency graph by 11 | default. 12 | - Nodes in the generated DOT graphs are now coloured based on their (parent) module's name. 13 | Test-only dependencies are distinguishable by a lighter colour-palette than core dependencies. 14 | Similary edges reflecting test-only dependencies are now marked with a distinct colour just as 15 | indirect module dependencies are reflected by dashed lines instead of continous ones. 16 | 17 | ## Breaking changes 18 | 19 | - The query syntax for paths has been modified in favour of using glob-based matching. As a result 20 | the `foo/...` prefix-matching is no longer recognised. Instead the more flexible `foo/**` can be 21 | used which also allows for middle-of-path wildcards such as `foo/**/bar/*`. 22 | -------------------------------------------------------------------------------- /docs/release-notes/v0.7.1.md: -------------------------------------------------------------------------------- 1 | # v0.7.1 2 | 3 | ## Bug fixes 4 | 5 | - [#115] Enforce module-mode when running `go list` in projects using vendoring. 6 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Helcaraxan/gomod 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/bmatcuk/doublestar/v3 v3.0.0 7 | github.com/spf13/cobra v1.1.1 8 | github.com/stretchr/testify v1.6.1 9 | go.uber.org/zap v1.16.0 10 | gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 11 | ) 12 | -------------------------------------------------------------------------------- /images/dependency-chains.dot: -------------------------------------------------------------------------------- 1 | strict digraph { 2 | node [shape=box,style="rounded,filled"] 3 | start=0 4 | concentrate=true 5 | "github.com/Helcaraxan/gomod" [fontcolor="0.000 0.000 0.000",fillcolor="0.824 0.753 1.000"] 6 | "github.com/bketelsen/crypt" [fontcolor="0.000 0.000 0.000",fillcolor="0.702 0.260 1.000",label=v0.0.3-0.20200106085610-5cbc8cc4026c>] 7 | "github.com/hashicorp/consul/api" [fontcolor="0.000 0.000 0.000",fillcolor="0.965 0.207 1.000",label=v1.1.0>] 8 | "github.com/hashicorp/memberlist" [fontcolor="0.000 0.000 0.000",fillcolor="0.302 0.340 1.000",label=v0.1.3>] 9 | "github.com/hashicorp/serf" [fontcolor="0.000 0.000 0.000",fillcolor="0.635 0.273 1.000",label=v0.8.2>] 10 | "github.com/prometheus/client_golang" [fontcolor="0.000 0.000 0.000",fillcolor="0.102 0.380 1.000",label=v0.9.3>] 11 | "github.com/prometheus/common" [fontcolor="0.000 0.000 0.000",fillcolor="0.384 0.323 1.000",label=v0.4.0>] 12 | "github.com/prometheus/tsdb" [fontcolor="0.000 0.000 0.000",fillcolor="0.945 0.211 1.000",label=v0.7.1>] 13 | "github.com/sirupsen/logrus" [fontcolor="0.000 0.000 0.000",fillcolor="0.592 0.282 1.000",label=v1.2.0>] 14 | "github.com/spf13/cast" [fontcolor="0.000 0.000 0.000",fillcolor="0.843 0.231 1.000",label=v1.3.0>] 15 | "github.com/spf13/cobra" [fontcolor="0.000 0.000 0.000",fillcolor="0.988 0.704 1.000",label=v1.1.1>] 16 | "github.com/spf13/viper" [fontcolor="0.000 0.000 0.000",fillcolor="0.533 0.293 1.000",label=v1.7.0>] 17 | "github.com/stretchr/testify" [fontcolor="0.000 0.000 0.000",fillcolor="0.569 0.829 1.000",label=v1.6.1>] 18 | "go.uber.org/atomic" [fontcolor="0.000 0.000 0.000",fillcolor="0.949 0.715 1.000",label=v1.6.0>] 19 | "go.uber.org/multierr" [fontcolor="0.000 0.000 0.000",fillcolor="0.345 0.896 1.000",label=v1.5.0>] 20 | "go.uber.org/zap" [fontcolor="0.000 0.000 0.000",fillcolor="1.000 0.700 1.000",label=v1.16.0>] 21 | "github.com/Helcaraxan/gomod" -> "github.com/spf13/cobra" [label=<v1.1.1>] 22 | "github.com/Helcaraxan/gomod" -> "github.com/stretchr/testify" [minlen=4,label=<v1.6.1>] 23 | "github.com/Helcaraxan/gomod" -> "go.uber.org/zap" [minlen=3,label=<v1.16.0>] 24 | "github.com/bketelsen/crypt" -> "github.com/hashicorp/consul/api" [color=lightblue,label=<v1.1.0>] 25 | "github.com/hashicorp/consul/api" -> "github.com/hashicorp/serf" [color=lightblue,label=<v0.8.2>] 26 | "github.com/hashicorp/consul/api" -> "github.com/stretchr/testify" [minlen=2,label=<v1.3.0>] 27 | "github.com/hashicorp/memberlist" -> "github.com/stretchr/testify" [label=<v1.2.2>] 28 | "github.com/hashicorp/serf" -> "github.com/hashicorp/memberlist" [color=lightblue,label=<v0.1.3>] 29 | "github.com/hashicorp/serf" -> "github.com/stretchr/testify" [minlen=2,style=dashed,label=<v1.3.0>] 30 | "github.com/prometheus/client_golang" -> "github.com/prometheus/common" [minlen=2,color=lightblue,label=<v0.4.0>] 31 | "github.com/prometheus/client_golang" -> "github.com/prometheus/tsdb" [minlen=3,color=lightblue,label=<v0.7.1>] 32 | "github.com/prometheus/common" -> "github.com/prometheus/client_golang" [color=lightblue,label=<v0.9.1>] 33 | "github.com/prometheus/common" -> "github.com/sirupsen/logrus" [minlen=4,color=lightblue,label=<v1.2.0>] 34 | "github.com/prometheus/tsdb" -> "github.com/prometheus/client_golang" [minlen=2,color=lightblue,label=<v0.9.1>] 35 | "github.com/prometheus/tsdb" -> "github.com/prometheus/common" [minlen=3,style=dashed,color=lightblue,label=<v0.0.0-20181113130724-41aa239b4cce>] 36 | "github.com/prometheus/tsdb" -> "github.com/stretchr/testify" [minlen=6,style=dashed,label=<v1.2.2>] 37 | "github.com/sirupsen/logrus" -> "github.com/stretchr/testify" [label=<v1.2.2>] 38 | "github.com/spf13/cast" -> "github.com/stretchr/testify" [label=<v1.2.2>] 39 | "github.com/spf13/cobra" -> "github.com/spf13/viper" [color=lightblue,label=<v1.7.0>] 40 | "github.com/spf13/viper" -> "github.com/bketelsen/crypt" [color=lightblue,label=<v0.0.3-0.20200106085610-5cbc8cc4026c>] 41 | "github.com/spf13/viper" -> "github.com/prometheus/client_golang" [style=dashed,color=lightblue,label=<v0.9.3>] 42 | "github.com/spf13/viper" -> "github.com/spf13/cast" [color=lightblue,label=<v1.3.0>] 43 | "github.com/spf13/viper" -> "github.com/stretchr/testify" [minlen=6,label=<v1.3.0>] 44 | "github.com/spf13/viper" -> "go.uber.org/atomic" [minlen=3,style=dashed,label=<v1.4.0>] 45 | "github.com/spf13/viper" -> "go.uber.org/multierr" [minlen=2,style=dashed,label=<v1.1.0>] 46 | "github.com/spf13/viper" -> "go.uber.org/zap" [style=dashed,label=<v1.10.0>] 47 | "go.uber.org/atomic" -> "github.com/stretchr/testify" [label=<v1.3.0>] 48 | "go.uber.org/multierr" -> "github.com/stretchr/testify" [minlen=2,label=<v1.3.0>] 49 | "go.uber.org/multierr" -> "go.uber.org/atomic" [label=<v1.6.0>] 50 | "go.uber.org/zap" -> "github.com/stretchr/testify" [minlen=2,label=<v1.4.0>] 51 | "go.uber.org/zap" -> "go.uber.org/atomic" [minlen=2,label=<v1.6.0>] 52 | "go.uber.org/zap" -> "go.uber.org/multierr" [label=<v1.5.0>] 53 | } 54 | -------------------------------------------------------------------------------- /images/dependency-chains.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Helcaraxan/gomod/7a27bfad203a324a521be71ad0581205840a635c/images/dependency-chains.jpg -------------------------------------------------------------------------------- /images/full.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Helcaraxan/gomod/7a27bfad203a324a521be71ad0581205840a635c/images/full.jpg -------------------------------------------------------------------------------- /images/shared-dependencies.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Helcaraxan/gomod/7a27bfad203a324a521be71ad0581205840a635c/images/shared-dependencies.jpg -------------------------------------------------------------------------------- /internal/analysis/analysis_test.go: -------------------------------------------------------------------------------- 1 | package analysis 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | "testing" 9 | "time" 10 | 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | 14 | "github.com/Helcaraxan/gomod/internal/depgraph" 15 | "github.com/Helcaraxan/gomod/internal/testutil" 16 | ) 17 | 18 | func Test_DistributionCountToPercentage(t *testing.T) { 19 | inputDistribution := []int{1, 10, 5, 4} 20 | expectedOutputNoGrouping := []float64{0.05, 0.5, 0.25, 0.20} 21 | expectedOutputThreeGroup := []float64{0.8, 0.2} 22 | assert.Equal(t, expectedOutputNoGrouping, distributionCountToPercentage(inputDistribution, 1)) 23 | assert.Equal(t, expectedOutputThreeGroup, distributionCountToPercentage(inputDistribution, 3)) 24 | } 25 | 26 | func Test_DistributionToLines(t *testing.T) { 27 | inputDistribution := []float64{0.05, 0.48, 0.35, 0.12} 28 | expectedOutput := []string{ 29 | "||||||", 30 | "__", 31 | "_", 32 | "_#####", 33 | "_", 34 | "_###_", 35 | "_", 36 | "_#", 37 | } 38 | assert.Equal(t, expectedOutput, distributionToLines(inputDistribution, 5)) 39 | } 40 | 41 | func Test_RotateDistributionLines(t *testing.T) { 42 | input := []string{ 43 | "||||||", 44 | "_##_", 45 | "_", 46 | "_#####", 47 | "__", 48 | } 49 | expected := []string{ 50 | "| # ", 51 | "| # ", 52 | "|_ # ", 53 | "|# # ", 54 | "|# #_", 55 | "|____", 56 | } 57 | assert.Equal(t, expected, rotateDistributionLines(input, 5), "Should have gotten the expected output") 58 | } 59 | 60 | type testcase struct { 61 | CurrentTime *time.Time `yaml:"now"` 62 | ListModOutput map[string]string `yaml:"go_list_mod_output"` 63 | ListPkgOutput map[string]string `yaml:"go_list_pkg_output"` 64 | GraphOutput string `yaml:"go_graph_output"` 65 | 66 | ExpectedDepAnalysis *DepAnalysis `yaml:"dep_analysis"` 67 | ExpectedPrintOutput string `yaml:"print_output"` 68 | } 69 | 70 | func (c *testcase) GoDriverError() bool { return false } 71 | func (c *testcase) GoListModOutput() map[string]string { return c.ListModOutput } 72 | func (c *testcase) GoListPkgOutput() map[string]string { return c.ListPkgOutput } 73 | func (c *testcase) GoGraphOutput() string { return c.GraphOutput } 74 | 75 | func TestAnalysis(t *testing.T) { 76 | cwd, setupErr := os.Getwd() 77 | require.NoError(t, setupErr) 78 | 79 | // Prepend the testdata directory to the path so we use the fake "go" script. 80 | setupErr = os.Setenv("PATH", filepath.Join(cwd, "..", "internal", "testutil")+":"+os.Getenv("PATH")) 81 | require.NoError(t, setupErr) 82 | 83 | files, setupErr := ioutil.ReadDir(filepath.Join(cwd, "testdata")) 84 | require.NoError(t, setupErr) 85 | 86 | for idx := range files { 87 | file := files[idx] 88 | if file.IsDir() || filepath.Ext(file.Name()) != ".yaml" { 89 | continue 90 | } 91 | 92 | testname := strings.TrimSuffix(file.Name(), ".yaml") 93 | t.Run(testname, func(t *testing.T) { 94 | testDefinition := &testcase{} 95 | testDir := testutil.SetupTestModule(t, filepath.Join(cwd, "testdata", file.Name()), testDefinition) 96 | 97 | if testDefinition.CurrentTime != nil { 98 | testCurrentTimeInjection = testDefinition.CurrentTime 99 | defer func() { testCurrentTimeInjection = nil }() 100 | } 101 | 102 | log := testutil.TestLogger(t) 103 | graph, err := depgraph.GetGraph(log, testDir) 104 | require.NoError(t, err) 105 | 106 | analysis, err := Analyse(log.Log(), graph) 107 | require.NoError(t, err) 108 | assert.Equal(t, testDefinition.ExpectedDepAnalysis, analysis) 109 | 110 | output := &strings.Builder{} 111 | err = analysis.Print(output) 112 | require.NoError(t, err) 113 | assert.Equal(t, testDefinition.ExpectedPrintOutput, output.String()) 114 | }) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /internal/analysis/testdata/DeprecatedUpdate.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | now: "2019-01-01T00:01:00Z" 3 | go_list_mod_output: 4 | test: | 5 | { 6 | "Path": "test", 7 | "Main": true 8 | } 9 | dep: | 10 | { 11 | "Path": "dep", 12 | "Time": "2019-01-01T00:00:00Z", 13 | "Version": "v1.0.0", 14 | "Update": { 15 | "Path": "dep", 16 | "Time": "2018-01-01T00:00:00Z", 17 | "Version": "v1.1.0" 18 | } 19 | } 20 | go_list_pkg_output: 21 | test/...: | 22 | { 23 | "ImportPath": "test", 24 | "Module": { 25 | "Path": "test", 26 | "Main": true 27 | } 28 | } 29 | go_graph_output: | 30 | test dep@v1.0.0 31 | dep_analysis: 32 | module: "test" 33 | direct_dependencies: 1 34 | indirect_dependencies: 0 35 | mean_age: 60000000000ns 36 | max_age: 60000000000ns 37 | age_per_month: 38 | - 1 39 | available_updates: 0 40 | mean_backlog: 0ns 41 | max_backlog: 0ns 42 | backlog_per_month: 43 | mean_reverse_deps: 1 44 | max_reverse_deps: 1 45 | reverse_deps_distribution: 46 | - 0 47 | - 1 48 | print_output: |+ 49 | -- Analysis for 'test' -- 50 | Dependency counts: 51 | - Direct dependencies: 1 52 | - Indirect dependencies: 0 53 | 54 | Age statistics: 55 | - Mean age of dependencies: 0 month(s) 0 day(s) 56 | - Maximum dependency age: 0 month(s) 0 day(s) 57 | - Age distribution per month: 58 | 59 | 100.00 % |# 60 | |# 61 | |# 62 | |# 63 | |# 64 | |# 65 | |# 66 | |# 67 | |# 68 | |# 69 | 0.00 % |_ 70 | 0 1 71 | 72 | Update backlog statistics: 73 | - No available updates. Congratulations you are entirely up-to-date! 74 | 75 | Reverse dependency statistics: 76 | - Mean number of reverse dependencies: 1.00 77 | - Maximum number of reverse dependencies: 1 78 | - Reverse dependency count distribution: 79 | 80 | 100.00 % | # 81 | | # 82 | | # 83 | | # 84 | | # 85 | | # 86 | | # 87 | | # 88 | | # 89 | | # 90 | 0.00 % |___ 91 | 0 2 92 | 93 | -------------------------------------------------------------------------------- /internal/analysis/testdata/MissingTimestamp.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | now: "2019-01-01T00:01:00Z" 3 | go_list_mod_output: 4 | test: | 5 | { 6 | "Path": "test", 7 | "Main": true 8 | } 9 | dep: | 10 | { 11 | "Path": "dep", 12 | "Time": "2019-01-01T00:00:00Z", 13 | "Version": "v1.0.0", 14 | "Update": { 15 | "Path": "dep", 16 | "Time": "2019-01-01T00:00:30Z", 17 | "Version": "v1.1.0" 18 | } 19 | } 20 | no_timestamp: | 21 | { 22 | "Path": "no_timestamp", 23 | "Version": "v0.0.1", 24 | "Update": { 25 | "Path": "no_timestamp", 26 | "Time": "2019-01-01T00:00:30Z", 27 | "Version": "v0.0.2" 28 | } 29 | } 30 | go_list_pkg_output: 31 | test/...: | 32 | { 33 | "ImportPath": "test", 34 | "Module": { 35 | "Path": "test", 36 | "Main": true 37 | } 38 | } 39 | 40 | go_graph_output: | 41 | test dep@v1.0.0 42 | test no_timestamp@v0.0.1 43 | dep_analysis: 44 | module: "test" 45 | direct_dependencies: 2 46 | indirect_dependencies: 0 47 | mean_age: 60000000000ns 48 | max_age: 60000000000ns 49 | age_per_month: 50 | - 1 51 | available_updates: 1 52 | available_updates_direct: 1 53 | mean_backlog: 30000000000ns 54 | max_backlog: 30000000000ns 55 | backlog_per_month: 56 | - 1 57 | mean_reverse_deps: 1 58 | max_reverse_deps: 1 59 | reverse_deps_distribution: 60 | - 0 61 | - 2 62 | print_output: |+ 63 | -- Analysis for 'test' -- 64 | Dependency counts: 65 | - Direct dependencies: 2 66 | - Indirect dependencies: 0 67 | 68 | Age statistics: 69 | - Mean age of dependencies: 0 month(s) 0 day(s) 70 | - Maximum dependency age: 0 month(s) 0 day(s) 71 | - Age distribution per month: 72 | 73 | 100.00 % |# 74 | |# 75 | |# 76 | |# 77 | |# 78 | |# 79 | |# 80 | |# 81 | |# 82 | |# 83 | 0.00 % |_ 84 | 0 1 85 | 86 | Update backlog statistics: 87 | - Number of dependencies with an update: 1 (of which 1 is direct) 88 | - Mean update backlog of dependencies: 0 month(s) 0 day(s) 89 | - Maximum update backlog of dependencies: 0 month(s) 0 day(s) 90 | - Update backlog distribution per month: 91 | 92 | 100.00 % |# 93 | |# 94 | |# 95 | |# 96 | |# 97 | |# 98 | |# 99 | |# 100 | |# 101 | |# 102 | 0.00 % |_ 103 | 0 1 104 | 105 | Reverse dependency statistics: 106 | - Mean number of reverse dependencies: 1.00 107 | - Maximum number of reverse dependencies: 1 108 | - Reverse dependency count distribution: 109 | 110 | 100.00 % | # 111 | | # 112 | | # 113 | | # 114 | | # 115 | | # 116 | | # 117 | | # 118 | | # 119 | | # 120 | 0.00 % |___ 121 | 0 2 122 | 123 | -------------------------------------------------------------------------------- /internal/analysis/testdata/NoUpdates.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | now: "2019-01-01T00:01:00Z" 3 | go_list_mod_output: 4 | test: | 5 | { 6 | "Path": "test", 7 | "Main": true 8 | } 9 | dep: | 10 | { 11 | "Path": "dep", 12 | "Time": "2019-01-01T00:00:00Z", 13 | "Version": "v1.0.0" 14 | } 15 | go_list_pkg_output: 16 | test/...: | 17 | { 18 | "ImportPath": "test", 19 | "Module": { 20 | "Path": "test", 21 | "Main": true 22 | } 23 | } 24 | go_graph_output: | 25 | test dep@v1.0.0 26 | dep_analysis: 27 | module: "test" 28 | direct_dependencies: 1 29 | indirect_dependencies: 0 30 | mean_age: 60000000000ns 31 | max_age: 60000000000ns 32 | age_per_month: 33 | - 1 34 | available_updates: 0 35 | mean_backlog: 0ns 36 | max_backlog: 0ns 37 | backlog_per_month: 38 | mean_reverse_deps: 1 39 | max_reverse_deps: 1 40 | reverse_deps_distribution: 41 | - 0 42 | - 1 43 | print_output: |+ 44 | -- Analysis for 'test' -- 45 | Dependency counts: 46 | - Direct dependencies: 1 47 | - Indirect dependencies: 0 48 | 49 | Age statistics: 50 | - Mean age of dependencies: 0 month(s) 0 day(s) 51 | - Maximum dependency age: 0 month(s) 0 day(s) 52 | - Age distribution per month: 53 | 54 | 100.00 % |# 55 | |# 56 | |# 57 | |# 58 | |# 59 | |# 60 | |# 61 | |# 62 | |# 63 | |# 64 | 0.00 % |_ 65 | 0 1 66 | 67 | Update backlog statistics: 68 | - No available updates. Congratulations you are entirely up-to-date! 69 | 70 | Reverse dependency statistics: 71 | - Mean number of reverse dependencies: 1.00 72 | - Maximum number of reverse dependencies: 1 73 | - Reverse dependency count distribution: 74 | 75 | 100.00 % | # 76 | | # 77 | | # 78 | | # 79 | | # 80 | | # 81 | | # 82 | | # 83 | | # 84 | | # 85 | 0.00 % |___ 86 | 0 2 87 | 88 | -------------------------------------------------------------------------------- /internal/analysis/testdata/OneDep.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | now: "2019-01-01T00:01:00Z" 3 | go_list_mod_output: 4 | test: | 5 | { 6 | "Path": "test", 7 | "Main": true 8 | } 9 | dep: | 10 | { 11 | "Path": "dep", 12 | "Time": "2019-01-01T00:00:00Z", 13 | "Version": "v1.0.0", 14 | "Update": { 15 | "Path": "dep", 16 | "Time": "2019-01-01T00:00:30Z", 17 | "Version": "v1.1.0" 18 | } 19 | } 20 | go_list_pkg_output: 21 | test/...: | 22 | { 23 | "ImportPath": "test", 24 | "Module": { 25 | "Path": "test", 26 | "Main": true 27 | } 28 | } 29 | go_graph_output: | 30 | test dep@v1.0.0 31 | dep_analysis: 32 | module: "test" 33 | direct_dependencies: 1 34 | indirect_dependencies: 0 35 | mean_age: 60000000000ns 36 | max_age: 60000000000ns 37 | age_per_month: 38 | - 1 39 | available_updates: 1 40 | available_updates_direct: 1 41 | mean_backlog: 30000000000ns 42 | max_backlog: 30000000000ns 43 | backlog_per_month: 44 | - 1 45 | mean_reverse_deps: 1 46 | max_reverse_deps: 1 47 | reverse_deps_distribution: 48 | - 0 49 | - 1 50 | print_output: |+ 51 | -- Analysis for 'test' -- 52 | Dependency counts: 53 | - Direct dependencies: 1 54 | - Indirect dependencies: 0 55 | 56 | Age statistics: 57 | - Mean age of dependencies: 0 month(s) 0 day(s) 58 | - Maximum dependency age: 0 month(s) 0 day(s) 59 | - Age distribution per month: 60 | 61 | 100.00 % |# 62 | |# 63 | |# 64 | |# 65 | |# 66 | |# 67 | |# 68 | |# 69 | |# 70 | |# 71 | 0.00 % |_ 72 | 0 1 73 | 74 | Update backlog statistics: 75 | - Number of dependencies with an update: 1 (of which 1 is direct) 76 | - Mean update backlog of dependencies: 0 month(s) 0 day(s) 77 | - Maximum update backlog of dependencies: 0 month(s) 0 day(s) 78 | - Update backlog distribution per month: 79 | 80 | 100.00 % |# 81 | |# 82 | |# 83 | |# 84 | |# 85 | |# 86 | |# 87 | |# 88 | |# 89 | |# 90 | 0.00 % |_ 91 | 0 1 92 | 93 | Reverse dependency statistics: 94 | - Mean number of reverse dependencies: 1.00 95 | - Maximum number of reverse dependencies: 1 96 | - Reverse dependency count distribution: 97 | 98 | 100.00 % | # 99 | | # 100 | | # 101 | | # 102 | | # 103 | | # 104 | | # 105 | | # 106 | | # 107 | | # 108 | 0.00 % |___ 109 | 0 2 110 | 111 | -------------------------------------------------------------------------------- /internal/analysis/testdata/OneDirectOneIndirect.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | now: "2019-01-01T00:01:00Z" 3 | go_list_mod_output: 4 | test: | 5 | { 6 | "Path": "test", 7 | "Main": true 8 | } 9 | dep1: | 10 | { 11 | "Path": "dep1", 12 | "Time": "2019-01-01T00:00:00Z", 13 | "Version": "v1.0.0", 14 | "Update": { 15 | "Path": "dep1", 16 | "Time": "2019-01-01T00:00:30Z", 17 | "Version": "v1.1.0" 18 | } 19 | } 20 | dep2: | 21 | { 22 | "Path": "dep2", 23 | "Time": "2019-01-01T00:00:30Z", 24 | "Version": "v0.1.0", 25 | "Update": { 26 | "Path": "dep2", 27 | "Time": "2019-01-01T00:00:40Z", 28 | "Version": "v0.2.0" 29 | } 30 | } 31 | go_list_pkg_output: 32 | test/...: | 33 | { 34 | "ImportPath": "test", 35 | "Module": { 36 | "Path": "test", 37 | "Main": true 38 | } 39 | } 40 | go_graph_output: | 41 | test dep1@v1.0.0 42 | dep1@v1.0.0 dep2@v0.1.0 43 | dep_analysis: 44 | module: "test" 45 | direct_dependencies: 1 46 | indirect_dependencies: 1 47 | mean_age: 45000000000ns 48 | max_age: 60000000000ns 49 | age_per_month: 50 | - 2 51 | available_updates: 2 52 | available_updates_direct: 1 53 | mean_backlog: 20000000000ns 54 | max_backlog: 30000000000ns 55 | backlog_per_month: 56 | - 2 57 | mean_reverse_deps: 1 58 | max_reverse_deps: 1 59 | reverse_deps_distribution: 60 | - 0 61 | - 2 62 | print_output: |+ 63 | -- Analysis for 'test' -- 64 | Dependency counts: 65 | - Direct dependencies: 1 66 | - Indirect dependencies: 1 67 | 68 | Age statistics: 69 | - Mean age of dependencies: 0 month(s) 0 day(s) 70 | - Maximum dependency age: 0 month(s) 0 day(s) 71 | - Age distribution per month: 72 | 73 | 100.00 % |# 74 | |# 75 | |# 76 | |# 77 | |# 78 | |# 79 | |# 80 | |# 81 | |# 82 | |# 83 | 0.00 % |_ 84 | 0 1 85 | 86 | Update backlog statistics: 87 | - Number of dependencies with an update: 2 (of which 1 is direct) 88 | - Mean update backlog of dependencies: 0 month(s) 0 day(s) 89 | - Maximum update backlog of dependencies: 0 month(s) 0 day(s) 90 | - Update backlog distribution per month: 91 | 92 | 100.00 % |# 93 | |# 94 | |# 95 | |# 96 | |# 97 | |# 98 | |# 99 | |# 100 | |# 101 | |# 102 | 0.00 % |_ 103 | 0 1 104 | 105 | Reverse dependency statistics: 106 | - Mean number of reverse dependencies: 1.00 107 | - Maximum number of reverse dependencies: 1 108 | - Reverse dependency count distribution: 109 | 110 | 100.00 % | # 111 | | # 112 | | # 113 | | # 114 | | # 115 | | # 116 | | # 117 | | # 118 | | # 119 | | # 120 | 0.00 % |___ 121 | 0 2 122 | 123 | -------------------------------------------------------------------------------- /internal/analysis/testdata/TwoDeps.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | now: "2019-01-01T00:01:00Z" 3 | go_list_mod_output: 4 | test: | 5 | { 6 | "Path": "test", 7 | "Main": true 8 | } 9 | dep1: | 10 | { 11 | "Path": "dep1", 12 | "Time": "2019-01-01T00:00:00Z", 13 | "Version": "v1.0.0", 14 | "Update": { 15 | "Path": "dep1", 16 | "Time": "2019-01-01T00:00:30Z", 17 | "Version": "v1.1.0" 18 | } 19 | } 20 | dep2: | 21 | { 22 | "Path": "dep2", 23 | "Time": "2019-01-01T00:00:30Z", 24 | "Version": "v0.1.0", 25 | "Update": { 26 | "Path": "dep2", 27 | "Time": "2019-01-01T00:00:40Z", 28 | "Version": "v0.2.0" 29 | } 30 | } 31 | go_list_pkg_output: 32 | test/...: | 33 | { 34 | "ImportPath": "test", 35 | "Module": { 36 | "Path": "test", 37 | "Main": true 38 | } 39 | } 40 | go_graph_output: | 41 | test dep1@v1.0.0 42 | test dep2@v0.1.0 43 | dep_analysis: 44 | module: "test" 45 | direct_dependencies: 2 46 | mean_age: 45000000000ns 47 | max_age: 60000000000ns 48 | age_per_month: 49 | - 2 50 | available_updates: 2 51 | available_updates_direct: 2 52 | mean_backlog: 20000000000ns 53 | max_backlog: 30000000000ns 54 | backlog_per_month: 55 | - 2 56 | mean_reverse_deps: 1 57 | max_reverse_deps: 1 58 | reverse_deps_distribution: 59 | - 0 60 | - 2 61 | print_output: |+ 62 | -- Analysis for 'test' -- 63 | Dependency counts: 64 | - Direct dependencies: 2 65 | - Indirect dependencies: 0 66 | 67 | Age statistics: 68 | - Mean age of dependencies: 0 month(s) 0 day(s) 69 | - Maximum dependency age: 0 month(s) 0 day(s) 70 | - Age distribution per month: 71 | 72 | 100.00 % |# 73 | |# 74 | |# 75 | |# 76 | |# 77 | |# 78 | |# 79 | |# 80 | |# 81 | |# 82 | 0.00 % |_ 83 | 0 1 84 | 85 | Update backlog statistics: 86 | - Number of dependencies with an update: 2 (of which 2 are direct) 87 | - Mean update backlog of dependencies: 0 month(s) 0 day(s) 88 | - Maximum update backlog of dependencies: 0 month(s) 0 day(s) 89 | - Update backlog distribution per month: 90 | 91 | 100.00 % |# 92 | |# 93 | |# 94 | |# 95 | |# 96 | |# 97 | |# 98 | |# 99 | |# 100 | |# 101 | 0.00 % |_ 102 | 0 1 103 | 104 | Reverse dependency statistics: 105 | - Mean number of reverse dependencies: 1.00 106 | - Maximum number of reverse dependencies: 1 107 | - Reverse dependency count distribution: 108 | 109 | 100.00 % | # 110 | | # 111 | | # 112 | | # 113 | | # 114 | | # 115 | | # 116 | | # 117 | | # 118 | | # 119 | 0.00 % |___ 120 | 0 2 121 | 122 | -------------------------------------------------------------------------------- /internal/depgraph/colours.go: -------------------------------------------------------------------------------- 1 | package depgraph 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "math/bits" 7 | ) 8 | 9 | func hashToColourHSV(hash string, isTest bool) (text string, background string) { 10 | var h byte 11 | for _, b := range []byte(hash) { 12 | h ^= bits.RotateLeft8(uint8(b), int(b)) 13 | } 14 | hue := float32(uint8(h)) / float32(math.MaxUint8) 15 | satVar := float32(uint8(h^0xff)) / float32(math.MaxUint8) 16 | 17 | text = "0.000 0.000 0.000" 18 | sat := 0.7 + 0.3*satVar 19 | if isTest { 20 | sat = 0.2 + 0.2*satVar 21 | } else if hue < 0.10 || (hue > 0.6 && hue < 0.8) { 22 | text = "0.000 0.000 1.000" 23 | } 24 | return text, fmt.Sprintf("%.3f %.3f 1.000", hue, sat) 25 | } 26 | -------------------------------------------------------------------------------- /internal/depgraph/deps_mod.go: -------------------------------------------------------------------------------- 1 | package depgraph 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "regexp" 7 | "strings" 8 | 9 | "go.uber.org/zap" 10 | 11 | "github.com/Helcaraxan/gomod/internal/logger" 12 | "github.com/Helcaraxan/gomod/internal/util" 13 | ) 14 | 15 | func (g *DepGraph) overlayModuleDependencies(dl *logger.Builder) error { 16 | log := dl.Domain(logger.ModuleDependencyDomain) 17 | log.Debug("Overlaying module-based dependency information over the import dependency graph.") 18 | 19 | raw, _, err := util.RunCommand(log, g.Main.Info.Dir, "go", "mod", "graph") 20 | if err != nil { 21 | return err 22 | } 23 | 24 | for _, depString := range strings.Split(strings.TrimSpace(string(raw)), "\n") { 25 | log.Debug("Parsing dependency", zap.String("reference", depString)) 26 | modDep, ok := g.parseDependency(log, depString) 27 | if !ok { 28 | continue 29 | } 30 | 31 | log.Debug( 32 | "Overlaying module dependency.", 33 | zap.String("version", modDep.targetVersion), 34 | zap.String("source", modDep.source.Name()), 35 | zap.String("target", modDep.target.Name()), 36 | ) 37 | err = g.Graph.AddEdge(modDep.source, modDep.target) 38 | if err != nil { 39 | return err 40 | } 41 | modDep.source.VersionConstraints[modDep.target.Hash()] = VersionConstraint{ 42 | Source: modDep.sourceVersion, 43 | Target: modDep.targetVersion, 44 | } 45 | } 46 | 47 | if err := g.markIndirects(log); err != nil { 48 | return err 49 | } 50 | 51 | return nil 52 | } 53 | 54 | type moduleDependency struct { 55 | source *Module 56 | sourceVersion string 57 | target *Module 58 | targetVersion string 59 | } 60 | 61 | func (g *DepGraph) parseDependency(log *logger.Logger, depString string) (*moduleDependency, bool) { 62 | depContent := depRE.FindStringSubmatch(depString) 63 | if len(depContent) == 0 { 64 | log.Warn("Skipping ill-formed line in 'go mod graph' output.", zap.String("line", depString)) 65 | return nil, false 66 | } 67 | 68 | sourceName, sourceVersion := depContent[1], depContent[2] 69 | targetName, targetVersion := depContent[3], depContent[4] 70 | 71 | source, ok := g.getModule(sourceName) 72 | if !ok { 73 | log.Warn("Encountered a dependency edge starting at an unknown module.", zap.String("source", sourceName), zap.String("target", targetName)) 74 | return nil, false 75 | } 76 | target, ok := g.getModule(targetName) 77 | if !ok { 78 | log.Warn("Encountered a dependency edge ending at an unknown module.", zap.String("source", sourceName), zap.String("target", targetName)) 79 | return nil, false 80 | 81 | } 82 | 83 | if sourceVersion != source.Info.Version { 84 | log.Debug( 85 | "Skipping edge as we are not using the specified source version.", 86 | zap.String("source", sourceName), 87 | zap.String("version", sourceVersion), 88 | zap.String("target", targetName), 89 | ) 90 | return nil, false 91 | } 92 | log.Debug( 93 | "Recording module dependency.", 94 | zap.String("source", sourceName), 95 | zap.String("version", sourceVersion), 96 | zap.String("target", targetName), 97 | ) 98 | 99 | return &moduleDependency{ 100 | source: source, 101 | sourceVersion: sourceVersion, 102 | target: target, 103 | targetVersion: targetVersion, 104 | }, true 105 | } 106 | 107 | func (g *DepGraph) markIndirects(log *logger.Logger) error { 108 | for _, node := range g.Graph.GetLevel(int(LevelModules)).List() { 109 | module := node.(*Module) 110 | 111 | log := log.With(zap.String("module", module.Name())) 112 | log.Debug("Finding indirect dependencies for module.") 113 | 114 | if module.Info.GoMod == "" { 115 | // This occurs when we are under tests and can be skipped safely. 116 | continue 117 | } 118 | 119 | modContent, err := ioutil.ReadFile(module.Info.GoMod) 120 | if err != nil { 121 | log.Error("Failed to read content of go.mod file.", zap.String("path", module.Info.GoMod), zap.Error(err)) 122 | return err 123 | } 124 | 125 | indirectDepRE := regexp.MustCompile(`^ ([^\s]+) [^\s]+ // indirect$`) 126 | for _, line := range bytes.Split(modContent, []byte("\n")) { 127 | if m := indirectDepRE.FindSubmatch(line); len(m) == 2 { 128 | log.Debug("Found indirect dependency.", zap.String("consumer", module.Name()), zap.String("dependency", string(m[1]))) 129 | module.Indirects[string(m[1])] = true 130 | } 131 | } 132 | } 133 | return nil 134 | } 135 | -------------------------------------------------------------------------------- /internal/depgraph/deps_pkg.go: -------------------------------------------------------------------------------- 1 | package depgraph 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "strings" 9 | 10 | "go.uber.org/zap" 11 | 12 | "github.com/Helcaraxan/gomod/internal/graph" 13 | "github.com/Helcaraxan/gomod/internal/logger" 14 | "github.com/Helcaraxan/gomod/internal/modules" 15 | "github.com/Helcaraxan/gomod/internal/util" 16 | ) 17 | 18 | func (g *DepGraph) buildImportGraph(dl *logger.Builder) error { 19 | log := dl.Domain(logger.PackageInfoDomain) 20 | log.Debug("Building initial dependency graph based on the import graph.") 21 | 22 | err := g.retrieveTransitiveImports(log, []string{fmt.Sprintf("%s/...", g.Main.Info.Path)}) 23 | if err != nil { 24 | return err 25 | } 26 | 27 | pkgs := g.Graph.GetLevel(int(LevelPackages)) 28 | for _, node := range pkgs.List() { 29 | pkg := node.(*Package) 30 | 31 | imports := pkg.Info.Imports 32 | if pkg.parent.Name() == g.Main.Name() { 33 | imports = append(imports, pkg.Info.TestImports...) 34 | imports = append(imports, pkg.Info.XTestImports...) 35 | } 36 | 37 | for _, imp := range imports { 38 | if isStandardLib(imp) { 39 | continue 40 | } 41 | 42 | targetNode, _ := pkgs.Get(packageHash(imp)) 43 | if targetNode == nil { 44 | log.Error("Detected import of unknown package.", zap.String("package", imp)) 45 | continue 46 | } 47 | 48 | log.Debug( 49 | "Adding package dependency.", 50 | zap.String("source", pkg.Name()), 51 | zap.String("source-module", pkg.Parent().Name()), 52 | zap.String("target", targetNode.Name()), 53 | zap.String("target-module", targetNode.Parent().Name()), 54 | ) 55 | targetPkg := targetNode.(*Package) 56 | if err = g.Graph.AddEdge(pkg, targetPkg); err != nil { 57 | return err 58 | } 59 | } 60 | } 61 | 62 | if err = g.markNonTestDependencies(log); err != nil { 63 | return err 64 | } 65 | 66 | return nil 67 | } 68 | 69 | func (g *DepGraph) markNonTestDependencies(log *logger.Logger) error { 70 | log.Debug("Marking non-test dependencies.") 71 | 72 | var todo []graph.Node 73 | seen := map[string]bool{} 74 | 75 | for _, mainPkg := range g.Main.packages.List() { 76 | if strings.HasSuffix(mainPkg.(*Package).Info.Name, "_test") { 77 | log.Debug("Skipping main module package as it is a test-only package.", zap.String("package", mainPkg.Name())) 78 | continue 79 | } 80 | 81 | todo = append(todo, mainPkg) 82 | seen[mainPkg.Name()] = true 83 | } 84 | 85 | for len(todo) > 0 { 86 | next := todo[0] 87 | todo = todo[1:] 88 | 89 | log.Debug("Marking package as non-test dependency.", zap.String("package", next.Name())) 90 | next.(*Package).isNonTestDependency = true 91 | next.Parent().(*Module).isNonTestDependency = true 92 | 93 | for _, imp := range next.(*Package).Info.Imports { 94 | if isStandardLib(imp) { 95 | continue 96 | } 97 | 98 | dep, err := g.Graph.GetNode(packageHash(imp)) 99 | if err != nil { 100 | return err 101 | } 102 | 103 | if !seen[dep.Name()] { 104 | todo = append(todo, dep) 105 | seen[dep.Name()] = true 106 | } 107 | } 108 | } 109 | return nil 110 | } 111 | 112 | func (g *DepGraph) retrieveTransitiveImports(log *logger.Logger, pkgs []string) error { 113 | const maxQueryLength = 950 // This is chosen conservatively to ensure we don't exceed maximum command lengths for 'go list' invocations. 114 | 115 | queued := map[string]bool{} 116 | for len(pkgs) > 0 { 117 | queryLength := 0 118 | 119 | cursor := 0 120 | for { 121 | if cursor == len(pkgs) || queryLength+len(pkgs[cursor]) > maxQueryLength { 122 | break 123 | } 124 | queryLength += len(pkgs[cursor]) + 1 125 | cursor++ 126 | } 127 | query := pkgs[:cursor] 128 | pkgs = pkgs[cursor:] 129 | 130 | imports, err := g.retrievePackageInfo(log, query) 131 | if err != nil { 132 | return err 133 | } 134 | 135 | for _, pkg := range imports { 136 | if !queued[pkg] { 137 | queued[pkg] = true 138 | pkgs = append(pkgs, pkg) 139 | } 140 | } 141 | } 142 | return nil 143 | } 144 | 145 | func (g *DepGraph) retrievePackageInfo(log *logger.Logger, pkgs []string) (imports []string, err error) { 146 | stdout, _, err := util.RunCommand(log, g.Main.Info.Dir, "go", append([]string{"list", "-json", "-mod=mod"}, pkgs...)...) 147 | if err != nil { 148 | log.Error("Failed to list imports for packages.", zap.Strings("packages", pkgs), zap.Error(err)) 149 | return nil, err 150 | } 151 | dec := json.NewDecoder(bytes.NewReader(stdout)) 152 | 153 | for { 154 | pkgInfo := &modules.PackageInfo{} 155 | if err = dec.Decode(pkgInfo); err != nil { 156 | if err == io.EOF { 157 | break 158 | } else { 159 | log.Error("Failed to parse go list output.", zap.Error(err)) 160 | return nil, err 161 | } 162 | } 163 | parentModule, ok := g.getModule(pkgInfo.Module.Path) 164 | if !ok { 165 | log.Error("Encountered package in unknown module.", zap.String("package", pkgInfo.ImportPath), zap.String("module", pkgInfo.Module.Path)) 166 | continue 167 | } 168 | 169 | pkg := NewPackage(pkgInfo, parentModule) 170 | _ = g.Graph.AddNode(pkg) 171 | log.Debug("Added import information for package", zap.String("package", pkg.Name()), zap.String("module", parentModule.Name())) 172 | 173 | importCandidates := make([]string, len(pkgInfo.Imports)) 174 | copy(importCandidates, pkgInfo.Imports) 175 | if parentModule.Name() == g.Main.Name() { 176 | importCandidates = append(importCandidates, pkgInfo.TestImports...) 177 | importCandidates = append(importCandidates, pkgInfo.XTestImports...) 178 | } 179 | 180 | for _, candidate := range importCandidates { 181 | if !isStandardLib(candidate) { 182 | imports = append(imports, candidate) 183 | } 184 | } 185 | } 186 | return imports, nil 187 | } 188 | 189 | func isStandardLib(pkg string) bool { 190 | return !strings.Contains(strings.Split(pkg, "/")[0], ".") 191 | } 192 | -------------------------------------------------------------------------------- /internal/depgraph/graph.go: -------------------------------------------------------------------------------- 1 | package depgraph 2 | 3 | import ( 4 | "github.com/Helcaraxan/gomod/internal/graph" 5 | "github.com/Helcaraxan/gomod/internal/logger" 6 | "github.com/Helcaraxan/gomod/internal/modules" 7 | ) 8 | 9 | // DepGraph represents a Go module's dependency graph. 10 | type DepGraph struct { 11 | Path string 12 | Main *Module 13 | Graph *graph.HierarchicalDigraph 14 | 15 | replaces map[string]string 16 | } 17 | 18 | type Level uint8 19 | 20 | const ( 21 | LevelModules Level = iota 22 | LevelPackages 23 | ) 24 | 25 | func NewGraph(log *logger.Logger, path string, main *modules.ModuleInfo) *DepGraph { 26 | g := &DepGraph{ 27 | Path: path, 28 | Graph: graph.NewHierarchicalDigraph(log), 29 | replaces: map[string]string{}, 30 | } 31 | g.Main = g.AddModule(main) 32 | return g 33 | } 34 | 35 | func (g *DepGraph) getModule(name string) (*Module, bool) { 36 | if replaced, ok := g.replaces[name]; ok { 37 | name = replaced 38 | } 39 | node, err := g.Graph.GetNode(moduleHash(name)) 40 | if err != nil { 41 | return nil, false 42 | } 43 | return node.(*Module), true 44 | } 45 | 46 | func (g *DepGraph) AddModule(module *modules.ModuleInfo) *Module { 47 | if module == nil { 48 | return nil 49 | } else if node, _ := g.Graph.GetNode(moduleHash(module.Path)); node != nil { 50 | return node.(*Module) 51 | } 52 | 53 | newModule := NewModule(module) 54 | 55 | _ = g.Graph.AddNode(newModule) 56 | if module.Replace != nil { 57 | g.replaces[module.Replace.Path] = module.Path 58 | } 59 | return newModule 60 | } 61 | -------------------------------------------------------------------------------- /internal/depgraph/module.go: -------------------------------------------------------------------------------- 1 | package depgraph 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/Helcaraxan/gomod/internal/graph" 8 | "github.com/Helcaraxan/gomod/internal/modules" 9 | ) 10 | 11 | // Module represents a module in a Go module's dependency graph. 12 | type Module struct { 13 | Info *modules.ModuleInfo 14 | Indirects map[string]bool 15 | VersionConstraints map[string]VersionConstraint 16 | 17 | predecessors graph.NodeRefs 18 | successors graph.NodeRefs 19 | 20 | packages graph.NodeRefs 21 | isNonTestDependency bool 22 | } 23 | 24 | type VersionConstraint struct { 25 | Source string 26 | Target string 27 | } 28 | 29 | func NewModule(info *modules.ModuleInfo) *Module { 30 | return &Module{ 31 | Info: info, 32 | Indirects: map[string]bool{}, 33 | VersionConstraints: map[string]VersionConstraint{}, 34 | predecessors: graph.NewNodeRefs(), 35 | successors: graph.NewNodeRefs(), 36 | packages: graph.NewNodeRefs(), 37 | } 38 | } 39 | 40 | // Name of the module represented by this Dependency in the Graph instance. 41 | func (m *Module) Name() string { 42 | return m.Info.Path 43 | } 44 | 45 | func (m *Module) Hash() string { 46 | return moduleHash(m.Info.Path) 47 | } 48 | 49 | func (m *Module) String() string { 50 | return fmt.Sprintf("%s, preds: [%s], succs: [%s]", m.Hash(), m.predecessors, m.successors) 51 | } 52 | 53 | func moduleHash(name string) string { 54 | return "module " + name 55 | } 56 | 57 | // SelectedVersion corresponds to the version of the dependency represented by this Dependency which 58 | // was selected for use. 59 | func (m *Module) SelectedVersion() string { 60 | if m.Info.Replace != nil { 61 | return m.Info.Replace.Version 62 | } 63 | return m.Info.Version 64 | } 65 | 66 | // Timestamp returns the time corresponding to the creation of the version at which this dependency 67 | // is used. 68 | func (m *Module) Timestamp() *time.Time { 69 | if m.Info.Replace != nil { 70 | return m.Info.Replace.Time 71 | } 72 | return m.Info.Time 73 | } 74 | 75 | func (m *Module) Parent() graph.Node { 76 | return nil 77 | } 78 | 79 | func (m *Module) Predecessors() *graph.NodeRefs { 80 | return &m.predecessors 81 | } 82 | 83 | func (m *Module) Successors() *graph.NodeRefs { 84 | return &m.successors 85 | } 86 | 87 | func (m *Module) Children() *graph.NodeRefs { 88 | return &m.packages 89 | } 90 | 91 | func (m *Module) NodeAttributes(annotate bool) []string { 92 | var annotations []string 93 | 94 | text, background := hashToColourHSV(m.Hash(), m.isTestDependency()) 95 | annotations = append(annotations, fmt.Sprintf(`fontcolor="%s"`, text), fmt.Sprintf(`fillcolor="%s"`, background)) 96 | 97 | if annotate && m.SelectedVersion() != "" { 98 | var replacement string 99 | if m.Info.Replace != nil { 100 | replacement = m.Info.Replace.Path + "
" 101 | } 102 | annotations = append( 103 | annotations, 104 | fmt.Sprintf("label=<%s
%s%s>", m.Name(), replacement, m.SelectedVersion()), 105 | ) 106 | } 107 | return annotations 108 | } 109 | 110 | func (m *Module) EdgeAttributes(target graph.Node, annotate bool) []string { 111 | targetModule := target.(*Module) 112 | 113 | var annotations []string 114 | if m.Indirects[target.Name()] { 115 | annotations = append(annotations, "style=dashed") //nolint:misspell 116 | } 117 | if target.(testAnnotated).isTestDependency() { 118 | annotations = append(annotations, "color=lightblue") //nolint:misspell 119 | } 120 | if c, ok := m.VersionConstraints[targetModule.Hash()]; ok && annotate { 121 | annotations = append(annotations, fmt.Sprintf("label=<%s>", c.Target)) 122 | } 123 | return annotations 124 | } 125 | 126 | var _ testAnnotated = &Module{} 127 | 128 | func (m *Module) isTestDependency() bool { 129 | return !m.isNonTestDependency 130 | } 131 | -------------------------------------------------------------------------------- /internal/depgraph/package.go: -------------------------------------------------------------------------------- 1 | package depgraph 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/Helcaraxan/gomod/internal/graph" 7 | "github.com/Helcaraxan/gomod/internal/modules" 8 | ) 9 | 10 | // Package represents a single Go package in a dependency graph. 11 | type Package struct { 12 | Info *modules.PackageInfo 13 | 14 | predecessors graph.NodeRefs 15 | successors graph.NodeRefs 16 | 17 | parent *Module 18 | isNonTestDependency bool 19 | } 20 | 21 | func NewPackage(info *modules.PackageInfo, parent *Module) *Package { 22 | return &Package{ 23 | Info: info, 24 | predecessors: graph.NewNodeRefs(), 25 | successors: graph.NewNodeRefs(), 26 | parent: parent, 27 | } 28 | } 29 | 30 | // Name returns the import path of the package and not the value declared inside the package with 31 | // the 'package' statement. 32 | func (p *Package) Name() string { 33 | return p.Info.ImportPath 34 | } 35 | 36 | func (p *Package) Hash() string { 37 | return packageHash(p.Info.ImportPath) 38 | } 39 | 40 | func (p *Package) String() string { 41 | return fmt.Sprintf("%s, module: %s, preds: [%s], succs: [%s]", p.Hash(), p.parent.Name(), p.predecessors, p.successors) 42 | } 43 | 44 | func packageHash(name string) string { return "package " + name } 45 | 46 | func (p *Package) Predecessors() *graph.NodeRefs { 47 | return &p.predecessors 48 | } 49 | 50 | func (p *Package) Successors() *graph.NodeRefs { 51 | return &p.successors 52 | } 53 | 54 | func (p *Package) Children() *graph.NodeRefs { 55 | return nil 56 | } 57 | 58 | func (p *Package) Parent() graph.Node { 59 | return p.parent 60 | } 61 | 62 | func (p *Package) NodeAttributes(annotate bool) []string { 63 | var annotations []string 64 | 65 | text, background := hashToColourHSV(p.Parent().Hash(), p.isTestDependency()) 66 | annotations = append(annotations, fmt.Sprintf(`fontcolor="%s"`, text), fmt.Sprintf(`fillcolor="%s"`, background)) 67 | 68 | return annotations 69 | } 70 | 71 | func (p *Package) EdgeAttributes(target graph.Node, annotate bool) []string { 72 | return nil 73 | } 74 | 75 | func (p *Package) isTestDependency() bool { 76 | return !p.isNonTestDependency 77 | } 78 | -------------------------------------------------------------------------------- /internal/depgraph/parser.go: -------------------------------------------------------------------------------- 1 | package depgraph 2 | 3 | import ( 4 | "os" 5 | "regexp" 6 | 7 | "go.uber.org/zap" 8 | 9 | "github.com/Helcaraxan/gomod/internal/graph" 10 | "github.com/Helcaraxan/gomod/internal/logger" 11 | "github.com/Helcaraxan/gomod/internal/modules" 12 | ) 13 | 14 | var depRE = regexp.MustCompile(`^([^@\s]+)@?([^@\s]+)? ([^@\s]+)@([^@\s]+)$`) 15 | 16 | // GetGraph will return the dependency graph for the Go module that can be found at the specified 17 | // path. 18 | func GetGraph(dl *logger.Builder, path string) (*DepGraph, error) { 19 | if dl == nil { 20 | dl = logger.NewBuilder(os.Stderr) 21 | } 22 | log := dl.Domain(logger.GraphDomain) 23 | log.Debug("Creating dependency graph.") 24 | 25 | mainModule, moduleInfo, err := modules.GetDependencies(dl.Domain(logger.ModuleInfoDomain), path) 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | g := NewGraph(log, path, mainModule) 31 | for _, module := range moduleInfo { 32 | g.AddModule(module) 33 | } 34 | 35 | if err = g.buildImportGraph(dl); err != nil { 36 | return nil, err 37 | } 38 | 39 | if err = g.overlayModuleDependencies(dl); err != nil { 40 | return nil, err 41 | } 42 | 43 | var roots []graph.Node 44 | for _, module := range g.Graph.GetLevel(0).List() { 45 | if module.Predecessors().Len() == 0 && module.Hash() != g.Main.Hash() { 46 | roots = append(roots, module) 47 | } 48 | } 49 | 50 | for len(roots) > 0 { 51 | next := roots[0] 52 | roots = roots[1:] 53 | 54 | for _, child := range next.Successors().List() { 55 | if child.Predecessors().Len() == 1 { 56 | roots = append(roots, child) 57 | } 58 | } 59 | 60 | log.Debug("Removing module as it not connected to the final graph.", zap.String("dependency", next.Name())) 61 | if err = g.Graph.DeleteNode(next.Hash()); err != nil { 62 | return nil, err 63 | } 64 | } 65 | 66 | return g, nil 67 | } 68 | -------------------------------------------------------------------------------- /internal/graph/graph.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "go.uber.org/zap" 8 | 9 | "github.com/Helcaraxan/gomod/internal/logger" 10 | ) 11 | 12 | var ( 13 | ErrNilGraph = errors.New("cannot operate on nil-graph") 14 | ErrNilNode = errors.New("cannot operate on nil-node") 15 | ErrNodeAlreadyExists = errors.New("node with identical hash already exists in graph") 16 | ErrNodeNotFound = errors.New("node not found") 17 | ErrEdgeSelf = errors.New("self-edges are not allowed") 18 | ErrEdgeCrossLevel = errors.New("edges not allowed between nodes of different hierarchical levels") 19 | ) 20 | 21 | type graphErr struct { 22 | err error 23 | ctx string 24 | } 25 | 26 | func (e graphErr) Error() string { 27 | return fmt.Sprintf("%s: %v", e.ctx, e.err) 28 | } 29 | 30 | func (e graphErr) Unwrap() error { 31 | return e.err 32 | } 33 | 34 | type HierarchicalDigraph struct { 35 | log *logger.Logger 36 | members NodeRefs 37 | } 38 | 39 | func NewHierarchicalDigraph(log *logger.Logger) *HierarchicalDigraph { 40 | return &HierarchicalDigraph{ 41 | log: log, 42 | members: NewNodeRefs(), 43 | } 44 | } 45 | 46 | func (g HierarchicalDigraph) GetNode(hash string) (Node, error) { 47 | n, _ := g.members.Get(hash) 48 | if n == nil { 49 | return nil, &graphErr{ 50 | err: ErrNodeNotFound, 51 | ctx: fmt.Sprintf("node hash %q", hash), 52 | } 53 | } 54 | return n, nil 55 | } 56 | 57 | func (g *HierarchicalDigraph) AddNode(node Node) error { 58 | if g == nil { 59 | return ErrNilGraph 60 | } else if nodeIsNil(node) { 61 | return ErrNilNode 62 | } 63 | g.log.Debug("Adding node to graph.", zap.Stringer("node", node)) 64 | 65 | if n, _ := g.members.Get(node.Hash()); n != nil { 66 | return &graphErr{ 67 | err: ErrNodeAlreadyExists, 68 | ctx: fmt.Sprintf("node hash %q", node.Hash()), 69 | } 70 | } 71 | 72 | if p := node.Parent(); !nodeIsNil(p) { 73 | if n, _ := g.members.Get(p.Hash()); nodeIsNil(n) { 74 | return &graphErr{ 75 | err: ErrNodeNotFound, 76 | ctx: fmt.Sprintf("node hash %q", node.Hash()), 77 | } 78 | } 79 | p.Children().Add(node) 80 | } 81 | g.members.Add(node) 82 | 83 | return nil 84 | } 85 | 86 | func (g *HierarchicalDigraph) DeleteNode(hash string) error { 87 | if g == nil { 88 | return ErrNilGraph 89 | } 90 | g.log.Debug("Deleting node from graph.", zap.String("hash", hash)) 91 | g.log.AddIndent() 92 | defer g.log.RemoveIndent() 93 | 94 | target, _ := g.members.Get(hash) 95 | if target == nil { 96 | return &graphErr{ 97 | err: ErrNodeNotFound, 98 | ctx: fmt.Sprintf("node hash %q", hash), 99 | } 100 | } 101 | 102 | for _, pred := range target.Predecessors().List() { 103 | g.disconnectNodeFromTarget(pred, target) 104 | } 105 | 106 | for _, succ := range target.Successors().List() { 107 | g.disconnectNodeFromTarget(target, succ) 108 | } 109 | 110 | g.deleteNode(target) 111 | 112 | for !nodeIsNil(target) { 113 | p := target.Parent() 114 | if nodeIsNil(p) || p.Children().Len() > 0 { 115 | break 116 | } 117 | 118 | g.log.AddIndent() 119 | defer g.log.RemoveIndent() 120 | 121 | if err := g.DeleteNode(p.Hash()); err != nil { 122 | return err 123 | } 124 | target = target.Parent() 125 | } 126 | return nil 127 | } 128 | 129 | func (g *HierarchicalDigraph) AddEdge(src Node, dst Node) error { 130 | if g == nil { 131 | return ErrNilGraph 132 | } else if nodeIsNil(src) || nodeIsNil(dst) { 133 | return ErrNilNode 134 | } 135 | 136 | if _, w := g.members.Get(src.Hash()); w == 0 { 137 | return &graphErr{ 138 | err: ErrNodeNotFound, 139 | ctx: fmt.Sprintf("node hash %q", src.Hash()), 140 | } 141 | } else if _, w := g.members.Get(dst.Hash()); w == 0 { 142 | return &graphErr{ 143 | err: ErrNodeNotFound, 144 | ctx: fmt.Sprintf("node hash %q", dst.Hash()), 145 | } 146 | } 147 | 148 | if nodeDepth(src) != nodeDepth(dst) { 149 | return &graphErr{ 150 | err: ErrEdgeCrossLevel, 151 | ctx: fmt.Sprintf("node %q (%d) - node %q (%d)", src.Hash(), nodeDepth(src), dst.Hash(), nodeDepth(dst)), 152 | } 153 | } 154 | 155 | for { 156 | if nodeIsNil(src) || nodeIsNil(dst) || src.Hash() == dst.Hash() { 157 | break 158 | } 159 | 160 | g.log.Debug("Adding edge to graph.", zap.String("source-hash", src.Hash()), zap.String("target-hash", dst.Hash())) 161 | src.Successors().Add(dst) 162 | dst.Predecessors().Add(src) 163 | 164 | src = src.Parent() 165 | dst = dst.Parent() 166 | g.log.AddIndent() 167 | defer g.log.RemoveIndent() 168 | } 169 | return nil 170 | } 171 | 172 | func (g *HierarchicalDigraph) DeleteEdge(src Node, dst Node) error { 173 | if g == nil { 174 | return ErrNilGraph 175 | } else if nodeIsNil(src) || nodeIsNil(dst) { 176 | return ErrNilNode 177 | } 178 | 179 | if _, w := g.members.Get(src.Hash()); w == 0 { 180 | return &graphErr{ 181 | err: ErrNodeNotFound, 182 | ctx: fmt.Sprintf("node hash %q", src.Hash()), 183 | } 184 | } else if _, w := g.members.Get(dst.Hash()); w == 0 { 185 | return &graphErr{ 186 | err: ErrNodeNotFound, 187 | ctx: fmt.Sprintf("node hash %q", dst.Hash()), 188 | } 189 | } 190 | 191 | g.disconnectNodeFromTarget(src, dst) 192 | 193 | for { 194 | g.log.AddIndent() 195 | defer g.log.RemoveIndent() 196 | 197 | src = src.Parent() 198 | dst = dst.Parent() 199 | if nodeIsNil(src) || nodeIsNil(dst) || src.Hash() == dst.Hash() { 200 | break 201 | } 202 | 203 | g.log.Debug("Unregistring edge from node parents.", zap.String("source-hash", src.Hash()), zap.String("target-hash", dst.Hash())) 204 | src.Successors().Delete(dst.Hash()) 205 | dst.Predecessors().Delete(src.Hash()) 206 | } 207 | return nil 208 | } 209 | 210 | func (g HierarchicalDigraph) GetLevel(level int) NodeRefs { 211 | refs := NewNodeRefs() 212 | for _, n := range g.members.nodeList { 213 | if nodeDepth(n) == level { 214 | refs.Add(n) 215 | } 216 | } 217 | return refs 218 | } 219 | 220 | func (g HierarchicalDigraph) disconnectNodeFromTarget(n Node, target Node) { 221 | g.log.Debug("Disconnecting nodes.", zap.String("source-hash", n.Hash()), zap.String("target-hash", target.Hash())) 222 | g.log.AddIndent() 223 | defer g.log.RemoveIndent() 224 | 225 | if n.Children() != nil && n.Children().Len() > 0 { 226 | for _, child := range n.Children().List() { 227 | g.disconnectNodeFromTarget(child, target) 228 | } 229 | } 230 | 231 | for _, succ := range n.Successors().List() { 232 | if isChild(succ, target) { 233 | g.log.Debug("Deleting edge from graph.", zap.String("source-hash", n.Hash()), zap.String("target-hash", succ.Hash())) 234 | n.Successors().Wipe(succ.Hash()) 235 | succ.Predecessors().Wipe(n.Hash()) 236 | g.log.Debug("New successors.", zap.String("hash", n.Hash()), zap.Stringer("succs", n.Successors())) 237 | } 238 | } 239 | } 240 | 241 | func (g HierarchicalDigraph) deleteNode(n Node) { 242 | if n.Children() != nil && n.Children().Len() > 0 { 243 | g.log.AddIndent() 244 | for _, child := range n.Children().List() { 245 | g.deleteNode(child) 246 | } 247 | g.log.RemoveIndent() 248 | } 249 | 250 | g.log.Debug("Removing node from members list.", zap.String("hash", n.Hash())) 251 | g.members.Delete(n.Hash()) 252 | if p := n.Parent(); !nodeIsNil(p) { 253 | p.Children().Delete(n.Hash()) 254 | } 255 | } 256 | 257 | func isChild(n Node, p Node) bool { 258 | if nodeIsNil(p) { 259 | return false 260 | } 261 | for !nodeIsNil(n) { 262 | if n.Hash() == p.Hash() { 263 | return true 264 | } 265 | n = n.Parent() 266 | } 267 | return false 268 | } 269 | -------------------------------------------------------------------------------- /internal/graph/graph_test.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | 10 | "github.com/Helcaraxan/gomod/internal/testutil" 11 | ) 12 | 13 | func TestGraphNodes(t *testing.T) { 14 | var g *HierarchicalDigraph 15 | 16 | n := newTestNode("test-node", nil) 17 | nc1 := newTestNode("test-node-child-1", n) 18 | nc2 := newTestNode("test-node-child-2", n) 19 | 20 | t.Run("NilGraph", func(t *testing.T) { 21 | assert.Equal(t, ErrNilGraph, g.AddNode(n)) 22 | assert.Equal(t, ErrNilGraph, g.DeleteNode(n.name)) 23 | }) 24 | 25 | g = NewHierarchicalDigraph(testutil.TestLogger(t).Log()) 26 | 27 | t.Run("AddNode", func(t *testing.T) { 28 | assert.Equal(t, ErrNilNode, g.AddNode(nil)) 29 | 30 | nf := newTestNode("non-member", nil) 31 | nfc := newTestNode("non-member-child", nf) 32 | assert.True(t, errors.Is(g.AddNode(nfc), ErrNodeNotFound)) 33 | 34 | assert.NoError(t, g.AddNode(n)) 35 | assert.Error(t, g.AddNode(n)) 36 | 37 | assert.NoError(t, g.AddNode(nc1)) 38 | assert.NoError(t, g.AddNode(nc2)) 39 | }) 40 | 41 | t.Run("GetNode", func(t *testing.T) { 42 | r, err := g.GetNode(n.name) 43 | assert.NoError(t, err) 44 | assert.Equal(t, n, r) 45 | 46 | l0 := g.GetLevel(0) 47 | assert.Equal(t, 1, l0.Len(), l0) 48 | m, _ := l0.Get(n.name) 49 | assert.Equal(t, n, m) 50 | 51 | l1 := g.GetLevel(1) 52 | assert.Equal(t, 2, l1.Len(), l1) 53 | m, _ = l1.Get(nc1.name) 54 | assert.Equal(t, nc1, m) 55 | m, _ = l1.Get(nc2.name) 56 | assert.Equal(t, nc2, m) 57 | }) 58 | 59 | t.Run("DeleteNode", func(t *testing.T) { 60 | assert.NoError(t, g.DeleteNode(nc2.name)) 61 | assert.True(t, errors.Is(g.DeleteNode(nc2.name), ErrNodeNotFound)) 62 | 63 | assert.NoError(t, g.DeleteNode(n.name), g.members) 64 | _, err := g.GetNode(nc1.name) 65 | assert.True(t, errors.Is(err, ErrNodeNotFound)) 66 | }) 67 | } 68 | 69 | func TestGraphEdges(t *testing.T) { 70 | var g *HierarchicalDigraph 71 | 72 | t.Run("NilGraph", func(t *testing.T) { 73 | assert.Equal(t, ErrNilGraph, g.AddEdge(nil, nil)) 74 | assert.Equal(t, ErrNilGraph, g.DeleteEdge(nil, nil)) 75 | }) 76 | 77 | g = NewHierarchicalDigraph(testutil.TestLogger(t).Log()) 78 | 79 | n1 := newTestNode("test-node-1", nil) 80 | n2 := newTestNode("test-node-2", nil) 81 | nc1 := newTestNode("test-node-child-1", n1) 82 | nc2 := newTestNode("test-node-child-2", n2) 83 | 84 | require.NoError(t, g.AddNode(n1)) 85 | require.NoError(t, g.AddNode(n2)) 86 | require.NoError(t, g.AddNode(nc1)) 87 | require.NoError(t, g.AddNode(nc2)) 88 | 89 | t.Run("NilNodes", func(t *testing.T) { 90 | assert.Equal(t, ErrNilNode, g.AddEdge(nil, nil)) 91 | assert.Equal(t, ErrNilNode, g.AddEdge(n1, nil)) 92 | assert.Equal(t, ErrNilNode, g.AddEdge(nil, n2)) 93 | 94 | assert.Equal(t, ErrNilNode, g.DeleteEdge(nil, nil)) 95 | assert.Equal(t, ErrNilNode, g.DeleteEdge(n1, nil)) 96 | assert.Equal(t, ErrNilNode, g.DeleteEdge(nil, n2)) 97 | }) 98 | 99 | t.Run("AddEdge", func(t *testing.T) { 100 | nf := newTestNode("non-member", nil) 101 | assert.True(t, errors.Is(g.AddEdge(n1, nf), ErrNodeNotFound)) 102 | assert.True(t, errors.Is(g.AddEdge(nf, n1), ErrNodeNotFound)) 103 | 104 | assert.True(t, errors.Is(g.AddEdge(n1, nc1), ErrEdgeCrossLevel)) 105 | 106 | assert.NoError(t, g.AddEdge(nc1, nc2)) 107 | 108 | m, w := nc1.Successors().Get(nc2.name) 109 | assert.Equal(t, nc2, m) 110 | assert.Equal(t, 1, w) 111 | m, w = nc2.Predecessors().Get(nc1.name) 112 | assert.Equal(t, nc1, m) 113 | assert.Equal(t, 1, w) 114 | m, w = n1.Successors().Get(n2.name) 115 | assert.Equal(t, n2, m) 116 | assert.Equal(t, 1, w) 117 | m, w = n2.Predecessors().Get(n1.name) 118 | assert.Equal(t, n1, m) 119 | assert.Equal(t, 1, w) 120 | 121 | assert.NoError(t, g.AddEdge(n1, n2)) 122 | m, w = n1.Successors().Get(n2.name) 123 | assert.Equal(t, n2, m) 124 | assert.Equal(t, 2, w) 125 | m, w = n2.Predecessors().Get(n1.name) 126 | assert.Equal(t, n1, m) 127 | assert.Equal(t, 2, w) 128 | }) 129 | 130 | t.Run("DeleteEdge", func(t *testing.T) { 131 | nf := newTestNode("non-member", nil) 132 | assert.True(t, errors.Is(g.DeleteEdge(n1, nf), ErrNodeNotFound)) 133 | assert.True(t, errors.Is(g.DeleteEdge(nf, n1), ErrNodeNotFound)) 134 | 135 | assert.NoError(t, g.DeleteEdge(nc2, nc1)) 136 | m, w := nc1.Successors().Get(nc2.name) 137 | assert.Equal(t, nc2, m) 138 | assert.Equal(t, 1, w) 139 | m, w = nc2.Predecessors().Get(nc1.name) 140 | assert.Equal(t, nc1, m) 141 | assert.Equal(t, 1, w) 142 | m, w = n1.Successors().Get(n2.name) 143 | assert.Equal(t, n2, m) 144 | assert.Equal(t, 2, w) 145 | m, w = n2.Predecessors().Get(n1.name) 146 | assert.Equal(t, n1, m) 147 | assert.Equal(t, 2, w) 148 | 149 | assert.NoError(t, g.DeleteEdge(nc1, nc2)) 150 | _, w = nc1.Successors().Get(nc2.name) 151 | assert.Equal(t, 0, w) 152 | _, w = nc2.Predecessors().Get(nc1.name) 153 | assert.Equal(t, 0, w) 154 | _, w = n1.Successors().Get(n2.name) 155 | assert.Equal(t, 1, w) 156 | _, w = n2.Predecessors().Get(n1.name) 157 | assert.Equal(t, 1, w) 158 | 159 | assert.NoError(t, g.AddEdge(nc1, nc2)) 160 | assert.NoError(t, g.DeleteEdge(n1, n2)) 161 | _, w = nc1.Successors().Get(nc2.name) 162 | assert.Equal(t, 0, w) 163 | _, w = nc2.Predecessors().Get(nc1.name) 164 | assert.Equal(t, 0, w) 165 | _, w = n1.Successors().Get(n2.name) 166 | assert.Equal(t, 0, w) 167 | _, w = n2.Predecessors().Get(n1.name) 168 | assert.Equal(t, 0, w) 169 | }) 170 | 171 | t.Run("DeleteNode", func(t *testing.T) { 172 | assert.NoError(t, g.AddEdge(n1, n2)) 173 | 174 | assert.NoError(t, g.DeleteNode(n2.name)) 175 | _, w := n1.Successors().Get(n2.name) 176 | assert.Equal(t, 0, w) 177 | _, err := g.GetNode(nc2.Hash()) 178 | assert.True(t, errors.Is(err, ErrNodeNotFound)) 179 | 180 | assert.NoError(t, g.AddNode(n2)) 181 | assert.NoError(t, g.AddEdge(n1, n2)) 182 | 183 | assert.NoError(t, g.DeleteNode(nc1.name)) 184 | _, w = n2.Predecessors().Get(n1.name) 185 | assert.Equal(t, 0, w) 186 | _, err = g.GetNode(n1.name) 187 | assert.True(t, errors.Is(err, ErrNodeNotFound)) 188 | }) 189 | } 190 | -------------------------------------------------------------------------------- /internal/graph/node.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | import ( 4 | "reflect" 5 | "sort" 6 | "strings" 7 | ) 8 | 9 | type Node interface { 10 | Name() string 11 | Hash() string 12 | String() string 13 | 14 | Predecessors() *NodeRefs 15 | Successors() *NodeRefs 16 | 17 | Parent() Node 18 | Children() *NodeRefs 19 | } 20 | 21 | func nodeIsNil(n Node) bool { 22 | return n == nil || reflect.ValueOf(n).IsNil() 23 | } 24 | 25 | func nodeDepth(n Node) int { 26 | depth := -1 27 | for !nodeIsNil(n) { 28 | depth++ 29 | n = n.Parent() 30 | } 31 | return depth 32 | } 33 | 34 | type NodeRefs struct { 35 | nodeList []Node 36 | nodeMap map[string]Node 37 | weights map[string]int 38 | } 39 | 40 | func NewNodeRefs() NodeRefs { 41 | return NodeRefs{ 42 | nodeList: []Node{}, 43 | nodeMap: map[string]Node{}, 44 | weights: map[string]int{}, 45 | } 46 | } 47 | 48 | func (n NodeRefs) String() string { 49 | var ns []string 50 | for _, m := range n.nodeList { 51 | ns = append(ns, m.Name()) 52 | } 53 | return strings.Join(ns, ", ") 54 | } 55 | 56 | func (n NodeRefs) Len() int { 57 | return len(n.nodeMap) 58 | } 59 | 60 | func (n *NodeRefs) Add(node Node) { 61 | if nodeIsNil(node) { 62 | return 63 | } 64 | 65 | h := node.Hash() 66 | n.weights[h]++ 67 | if _, ok := n.nodeMap[h]; !ok { 68 | n.nodeMap[h] = node 69 | n.nodeList = append(n.nodeList, node) 70 | } 71 | } 72 | 73 | func (n NodeRefs) Get(hash string) (Node, int) { 74 | return n.nodeMap[hash], n.weights[hash] 75 | } 76 | 77 | func (n *NodeRefs) Delete(hash string) { 78 | if n == nil { 79 | return 80 | } 81 | 82 | if _, ok := n.nodeMap[hash]; !ok { 83 | return 84 | } 85 | 86 | n.weights[hash]-- 87 | if n.weights[hash] > 0 { 88 | return 89 | } 90 | 91 | delete(n.nodeMap, hash) 92 | delete(n.weights, hash) 93 | 94 | for idx := range n.nodeList { 95 | if n.nodeList[idx].Hash() == hash { 96 | n.nodeList = append(n.nodeList[:idx], n.nodeList[idx+1:]...) 97 | break 98 | } 99 | } 100 | } 101 | 102 | func (n *NodeRefs) Wipe(hash string) { 103 | if n == nil { 104 | return 105 | } 106 | 107 | if _, ok := n.nodeMap[hash]; !ok { 108 | return 109 | } 110 | 111 | delete(n.nodeMap, hash) 112 | delete(n.weights, hash) 113 | 114 | for idx := range n.nodeList { 115 | if n.nodeList[idx].Hash() == hash { 116 | n.nodeList = append(n.nodeList[:idx], n.nodeList[idx+1:]...) 117 | break 118 | } 119 | } 120 | } 121 | 122 | func (n NodeRefs) List() []Node { 123 | sort.Slice(n.nodeList, func(i int, j int) bool { return n.nodeList[i].Name() < n.nodeList[j].Name() }) 124 | listCopy := make([]Node, len(n.nodeList)) 125 | copy(listCopy, n.nodeList) 126 | return listCopy 127 | } 128 | -------------------------------------------------------------------------------- /internal/graph/node_test.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | type testNode struct { 12 | name string 13 | preds NodeRefs 14 | succs NodeRefs 15 | parent *testNode 16 | children NodeRefs 17 | } 18 | 19 | func (n *testNode) Name() string { return n.name } 20 | func (n *testNode) Hash() string { return n.name } 21 | func (n *testNode) String() string { 22 | return fmt.Sprintf("%s, preds: [%s], succs: [%s]", n.name, n.preds, n.succs) 23 | } 24 | func (n *testNode) Predecessors() *NodeRefs { return &n.preds } 25 | func (n *testNode) Successors() *NodeRefs { return &n.succs } 26 | func (n *testNode) Parent() Node { return n.parent } 27 | func (n *testNode) Children() *NodeRefs { return &n.children } 28 | 29 | func newTestNode(name string, parent *testNode) *testNode { 30 | return &testNode{ 31 | name: name, 32 | preds: NewNodeRefs(), 33 | succs: NewNodeRefs(), 34 | parent: parent, 35 | children: NewNodeRefs(), 36 | } 37 | } 38 | 39 | func TestEdgesNew(t *testing.T) { 40 | t.Parallel() 41 | 42 | newMap := NewNodeRefs() 43 | assert.NotNil(t, newMap.nodeMap) 44 | assert.NotNil(t, newMap.nodeList) 45 | } 46 | 47 | func TestEdgesAdd(t *testing.T) { 48 | t.Parallel() 49 | 50 | dependencyA := testNode{name: "dependency_a"} 51 | 52 | edges := NewNodeRefs() 53 | edges.Add(&dependencyA) 54 | _, w := edges.Get("dependency_a") 55 | assert.Equal(t, 1, w) 56 | edges.Add(&dependencyA) 57 | _, w = edges.Get("dependency_a") 58 | assert.Equal(t, 2, w) 59 | } 60 | 61 | func TestEdgesDelete(t *testing.T) { 62 | t.Parallel() 63 | 64 | dependencyA := testNode{name: "dependency_a"} 65 | 66 | edges := NewNodeRefs() 67 | 68 | edges.Delete("dependency_a") 69 | assert.Equal(t, 0, edges.Len()) 70 | 71 | edges.Add(&dependencyA) 72 | edges.Delete("dependency_a") 73 | assert.Equal(t, 0, edges.Len()) 74 | } 75 | 76 | func TestEdgesLen(t *testing.T) { 77 | t.Parallel() 78 | 79 | dependencyA := testNode{name: "dependency_a"} 80 | dependencyB := testNode{name: "dependency_b"} 81 | 82 | edges := NewNodeRefs() 83 | assert.Equal(t, 0, edges.Len()) 84 | 85 | edges.Add(&dependencyA) 86 | assert.Equal(t, 1, edges.Len()) 87 | 88 | edges.Add(&dependencyA) 89 | assert.Equal(t, 1, edges.Len()) 90 | 91 | edges.Add(&dependencyB) 92 | assert.Equal(t, 2, edges.Len()) 93 | 94 | edges.Delete("dependency_a") 95 | assert.Equal(t, 2, edges.Len()) 96 | 97 | edges.Delete("dependency_b") 98 | assert.Equal(t, 1, edges.Len()) 99 | } 100 | 101 | func TestEdgesList(t *testing.T) { 102 | t.Parallel() 103 | 104 | dependencyA := testNode{name: "dependency_a"} 105 | dependencyB := testNode{name: "dependency_b"} 106 | 107 | edges := NewNodeRefs() 108 | edges.Add(&dependencyB) 109 | edges.Add(&dependencyA) 110 | 111 | list := edges.List() 112 | assert.True(t, sort.SliceIsSorted(list, func(i int, j int) bool { return list[i].Name() < list[j].Name() })) 113 | } 114 | -------------------------------------------------------------------------------- /internal/logger/encoder.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "go.uber.org/zap/buffer" 9 | "go.uber.org/zap/zapcore" 10 | ) 11 | 12 | func newEncoder() *goModEncoder { 13 | return &goModEncoder{ 14 | Encoder: zapcore.NewConsoleEncoder(fieldEncoderConfig), 15 | EncoderConfig: externalEncoderConfig, 16 | } 17 | } 18 | 19 | type goModEncoder struct { 20 | zapcore.Encoder 21 | zapcore.EncoderConfig 22 | 23 | indent int 24 | } 25 | 26 | func (c goModEncoder) EncodeEntry(ent zapcore.Entry, fields []zapcore.Field) (*buffer.Buffer, error) { 27 | line := pool.Get() 28 | 29 | arr := &sliceArrayEncoder{} 30 | c.EncodeTime(ent.Time, arr) 31 | c.EncodeLevel(ent.Level, arr) 32 | c.EncodeName(ent.LoggerName, arr) 33 | if c.indent > 0 { 34 | arr.AppendString(strings.Repeat("-", c.indent)) 35 | } 36 | if ent.Caller.Defined && c.EncodeCaller != nil { 37 | c.EncodeCaller(ent.Caller, arr) 38 | } 39 | arr.AppendString(ent.Message) 40 | 41 | for i := range arr.elems { 42 | if i > 0 { 43 | line.AppendByte(' ') 44 | } 45 | fmt.Fprint(line, arr.elems[i]) 46 | } 47 | 48 | b, err := c.Encoder.EncodeEntry(zapcore.Entry{}, fields) 49 | switch { 50 | case err != nil: 51 | return nil, err 52 | case b.Len() > 0: 53 | line.AppendByte(' ') 54 | if _, err = line.Write(b.Bytes()); err != nil { 55 | return nil, err 56 | } 57 | default: 58 | line.AppendString("\n") 59 | } 60 | 61 | return line, nil 62 | } 63 | 64 | var ( 65 | pool = buffer.NewPool() 66 | 67 | externalEncoderConfig = zapcore.EncoderConfig{ 68 | LevelKey: "level", 69 | TimeKey: "time", 70 | MessageKey: "msg", 71 | CallerKey: "caller", 72 | EncodeLevel: zapcore.LowercaseColorLevelEncoder, 73 | EncodeTime: func(t time.Time, enc zapcore.PrimitiveArrayEncoder) { enc.AppendString(t.Format("15:04:05")) }, 74 | EncodeDuration: zapcore.MillisDurationEncoder, 75 | EncodeCaller: zapcore.FullCallerEncoder, 76 | EncodeName: zapcore.FullNameEncoder, 77 | } 78 | fieldEncoderConfig = zapcore.EncoderConfig{ 79 | // We omit any of the keys so that we don't print any of the already previously printed fields. 80 | EncodeLevel: zapcore.LowercaseColorLevelEncoder, 81 | EncodeTime: func(t time.Time, enc zapcore.PrimitiveArrayEncoder) { enc.AppendString(t.Format("15:04:05")) }, 82 | EncodeDuration: zapcore.MillisDurationEncoder, 83 | EncodeCaller: zapcore.FullCallerEncoder, 84 | } 85 | ) 86 | 87 | type sliceArrayEncoder struct { 88 | elems []interface{} 89 | } 90 | 91 | func (s *sliceArrayEncoder) AppendArray(v zapcore.ArrayMarshaler) error { 92 | enc := &sliceArrayEncoder{} 93 | err := v.MarshalLogArray(enc) 94 | s.elems = append(s.elems, enc.elems) 95 | return err 96 | } 97 | 98 | func (s *sliceArrayEncoder) AppendObject(v zapcore.ObjectMarshaler) error { 99 | m := zapcore.NewMapObjectEncoder() 100 | err := v.MarshalLogObject(m) 101 | s.elems = append(s.elems, m.Fields) 102 | return err 103 | } 104 | 105 | func (s *sliceArrayEncoder) AppendReflected(v interface{}) error { 106 | s.elems = append(s.elems, v) 107 | return nil 108 | } 109 | 110 | func (s *sliceArrayEncoder) AppendBool(v bool) { s.elems = append(s.elems, v) } 111 | func (s *sliceArrayEncoder) AppendByteString(v []byte) { s.elems = append(s.elems, string(v)) } 112 | func (s *sliceArrayEncoder) AppendComplex128(v complex128) { s.elems = append(s.elems, v) } 113 | func (s *sliceArrayEncoder) AppendComplex64(v complex64) { s.elems = append(s.elems, v) } 114 | func (s *sliceArrayEncoder) AppendDuration(v time.Duration) { s.elems = append(s.elems, v) } 115 | func (s *sliceArrayEncoder) AppendFloat64(v float64) { s.elems = append(s.elems, v) } 116 | func (s *sliceArrayEncoder) AppendFloat32(v float32) { s.elems = append(s.elems, v) } 117 | func (s *sliceArrayEncoder) AppendInt(v int) { s.elems = append(s.elems, v) } 118 | func (s *sliceArrayEncoder) AppendInt64(v int64) { s.elems = append(s.elems, v) } 119 | func (s *sliceArrayEncoder) AppendInt32(v int32) { s.elems = append(s.elems, v) } 120 | func (s *sliceArrayEncoder) AppendInt16(v int16) { s.elems = append(s.elems, v) } 121 | func (s *sliceArrayEncoder) AppendInt8(v int8) { s.elems = append(s.elems, v) } 122 | func (s *sliceArrayEncoder) AppendString(v string) { s.elems = append(s.elems, v) } 123 | func (s *sliceArrayEncoder) AppendTime(v time.Time) { s.elems = append(s.elems, v) } 124 | func (s *sliceArrayEncoder) AppendUint(v uint) { s.elems = append(s.elems, v) } 125 | func (s *sliceArrayEncoder) AppendUint64(v uint64) { s.elems = append(s.elems, v) } 126 | func (s *sliceArrayEncoder) AppendUint32(v uint32) { s.elems = append(s.elems, v) } 127 | func (s *sliceArrayEncoder) AppendUint16(v uint16) { s.elems = append(s.elems, v) } 128 | func (s *sliceArrayEncoder) AppendUint8(v uint8) { s.elems = append(s.elems, v) } 129 | func (s *sliceArrayEncoder) AppendUintptr(v uintptr) { s.elems = append(s.elems, v) } 130 | -------------------------------------------------------------------------------- /internal/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "go.uber.org/zap" 5 | "go.uber.org/zap/zapcore" 6 | ) 7 | 8 | type Domain uint8 9 | 10 | const ( 11 | UnknownDomain Domain = iota 12 | AllDomain 13 | InitDomain 14 | GraphDomain 15 | ModuleInfoDomain 16 | PackageInfoDomain 17 | ModuleDependencyDomain 18 | ParserDomain 19 | QueryDomain 20 | PrinterDomain 21 | ) 22 | 23 | func domainFromString(domain string) Domain { 24 | return map[string]Domain{ 25 | "all": AllDomain, 26 | "init": InitDomain, 27 | "graph": GraphDomain, 28 | "modinfo": ModuleInfoDomain, 29 | "pkginfo": PackageInfoDomain, 30 | "moddeps": ModuleDependencyDomain, 31 | "parser": ParserDomain, 32 | "query": QueryDomain, 33 | "printer": PrinterDomain, 34 | }[domain] 35 | } 36 | 37 | func stringFromDomain(domain Domain) string { 38 | return map[Domain]string{ 39 | AllDomain: "all", 40 | InitDomain: "init", 41 | GraphDomain: "graph", 42 | ModuleInfoDomain: "modinfo", 43 | PackageInfoDomain: "pkginfo", 44 | ModuleDependencyDomain: "moddeps", 45 | ParserDomain: "parser", 46 | QueryDomain: "query", 47 | PrinterDomain: "printer", 48 | }[domain] 49 | } 50 | 51 | type Builder struct { 52 | log *zap.Logger 53 | enc *goModEncoder 54 | defaultLevel zapcore.Level 55 | domainLevels map[Domain]zapcore.Level 56 | cache map[Domain]*Logger 57 | } 58 | 59 | func NewBuilder(out zapcore.WriteSyncer) *Builder { 60 | enc := newEncoder() 61 | return &Builder{ 62 | log: zap.New(zapcore.NewCore(enc, out, zapcore.DebugLevel)), 63 | enc: enc, 64 | domainLevels: map[Domain]zapcore.Level{}, 65 | cache: map[Domain]*Logger{}, 66 | } 67 | } 68 | 69 | func (b *Builder) SetDomainLevel(domain string, level zapcore.Level) { 70 | d := domainFromString(domain) 71 | switch d { 72 | case UnknownDomain: 73 | b.log.Warn("Unrecognised logger domain.") 74 | case AllDomain: 75 | b.defaultLevel = level 76 | default: 77 | b.domainLevels[d] = level 78 | } 79 | } 80 | 81 | func (b *Builder) Log() *Logger { 82 | return b.logger(AllDomain) 83 | } 84 | 85 | func (b *Builder) Domain(domain Domain) *Logger { 86 | return b.logger(domain) 87 | } 88 | 89 | func (b *Builder) logger(domain Domain) *Logger { 90 | if _, ok := b.cache[domain]; !ok { 91 | targetLevel := b.defaultLevel 92 | if lvl, ok := b.domainLevels[domain]; ok { 93 | targetLevel = lvl 94 | } 95 | b.cache[domain] = &Logger{ 96 | Logger: b.log.Named(stringFromDomain(domain)).WithOptions(zap.IncreaseLevel(targetLevel)), 97 | enc: b.enc, 98 | } 99 | } 100 | return b.cache[domain] 101 | } 102 | 103 | type Logger struct { 104 | *zap.Logger 105 | enc *goModEncoder 106 | } 107 | 108 | func (l *Logger) AddIndent() { 109 | l.enc.indent++ 110 | } 111 | 112 | func (l *Logger) RemoveIndent() { 113 | if l.enc.indent > 0 { 114 | l.enc.indent-- 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /internal/modules/modules.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "time" 9 | 10 | "go.uber.org/zap" 11 | 12 | "github.com/Helcaraxan/gomod/internal/logger" 13 | "github.com/Helcaraxan/gomod/internal/util" 14 | ) 15 | 16 | // ModuleInfo represents the data returned by 'go list -m --json' for a Go module. It's content is 17 | // extracted directly from the Go documentation. 18 | type ModuleInfo struct { 19 | Main bool // is this the main module? 20 | Indirect bool // is it an indirect dependency? 21 | Path string // module path 22 | Replace *ModuleInfo // replaced by this module 23 | Version string // module version 24 | Time *time.Time // time version was created 25 | Dir string // location of the module's source 26 | Update *ModuleInfo // available update, if any (with -u) 27 | GoMod string // the path to this module's go.mod file 28 | GoVersion string // the Go version associated with the module 29 | Error *ModuleError // error loading module 30 | } 31 | 32 | // ModuleError represents the data that is returned whenever Go tooling was unable to load a given 33 | // module's information. 34 | type ModuleError struct { 35 | Err string // the error itself 36 | } 37 | 38 | // Retrieve the Module information for all dependencies of the Go module found at the specified path. 39 | func GetDependencies(log *logger.Logger, moduleDir string) (*ModuleInfo, map[string]*ModuleInfo, error) { 40 | return retrieveModuleInformation(log, moduleDir, "all") 41 | } 42 | 43 | // Retrieve the Module information for all dependencies of the Go module found at the specified 44 | // path, including any potentially available updates. This requires internet connectivity in order 45 | // to return the results. Lack of connectivity should result in an error being returned but this is 46 | // not a hard guarantee. 47 | func GetDependenciesWithUpdates(log *logger.Logger, moduleDir string) (*ModuleInfo, map[string]*ModuleInfo, error) { 48 | return retrieveModuleInformation(log, moduleDir, "all", "-versions", "-u") 49 | } 50 | 51 | // Retrieve the Module information for the specified target module which must be a dependency of the 52 | // Go module found at the specified path. 53 | func GetModule(log *logger.Logger, moduleDir string, targetModule string) (*ModuleInfo, error) { 54 | module, _, err := retrieveModuleInformation(log, moduleDir, targetModule) 55 | return module, err 56 | } 57 | 58 | // Retrieve the Module information for the specified target module which must be a dependency of the 59 | // Go module found at the specified path, including any potentially available updates. This requires 60 | // internet connectivity in order to return the results. Lack of connectivity should result in an 61 | // error being returned but this is not a hard guarantee. 62 | func GetModuleWithUpdate(log *logger.Logger, moduleDir string, targetModule string) (*ModuleInfo, error) { 63 | module, _, err := retrieveModuleInformation(log, moduleDir, targetModule, "-versions", "-u") 64 | return module, err 65 | } 66 | 67 | func retrieveModuleInformation( 68 | log *logger.Logger, 69 | moduleDir string, 70 | targetModule string, 71 | extraGoListArgs ...string, 72 | ) (*ModuleInfo, map[string]*ModuleInfo, error) { 73 | log.Debug("Ensuring module information is available locally by running 'go mod download'.") 74 | _, _, err := util.RunCommand(log, moduleDir, "go", "mod", "download") 75 | if err != nil { 76 | log.Error("Failed to run 'go mod download'.", zap.Error(err)) 77 | return nil, nil, err 78 | } 79 | 80 | log.Debug("Retrieving module information via 'go list'") 81 | goListArgs := append([]string{"list", "-json", "-m", "-mod=mod"}, extraGoListArgs...) 82 | if targetModule == "" { 83 | targetModule = "all" 84 | } 85 | goListArgs = append(goListArgs, targetModule) 86 | 87 | raw, _, err := util.RunCommand(log, moduleDir, "go", goListArgs...) 88 | if err != nil { 89 | log.Error("Failed to list modules in dependency graph via 'go list'.", zap.Error(err)) 90 | return nil, nil, err 91 | } 92 | raw = bytes.ReplaceAll(bytes.TrimSpace(raw), []byte("\n}\n"), []byte("\n},\n")) 93 | raw = append([]byte("[\n"), raw...) 94 | raw = append(raw, []byte("\n]")...) 95 | 96 | var moduleList []*ModuleInfo 97 | if err = json.Unmarshal(raw, &moduleList); err != nil { 98 | return nil, nil, fmt.Errorf("Unable to retrieve information from 'go list': %v", err) 99 | } 100 | 101 | var main *ModuleInfo 102 | modules := map[string]*ModuleInfo{} 103 | for _, module := range moduleList { 104 | if module.Error != nil { 105 | log.Warn("Unable to retrieve information for module", zap.String("module", module.Path), zap.String("error", module.Error.Err)) 106 | continue 107 | } 108 | 109 | if module.Main { 110 | main = module 111 | } 112 | modules[module.Path] = module 113 | if module.Replace != nil { 114 | modules[module.Replace.Path] = module 115 | } 116 | } 117 | if len(modules) == 0 { 118 | return nil, nil, errors.New("unable to load any module information") 119 | } 120 | return main, modules, nil 121 | } 122 | -------------------------------------------------------------------------------- /internal/modules/modules_test.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/Helcaraxan/gomod/internal/testutil" 14 | ) 15 | 16 | type testcase struct { 17 | DriverError bool `yaml:"driver_error"` 18 | ListModOutput map[string]string `yaml:"go_list_mod_output"` 19 | ListPkgOutput map[string]string `yaml:"go_list_pkg_output"` 20 | 21 | ExpectedError bool `yaml:"error"` 22 | ExpectedMain *ModuleInfo `yaml:"main"` 23 | ExpectedModules map[string]*ModuleInfo `yaml:"modules"` 24 | } 25 | 26 | func (c *testcase) GoDriverError() bool { return c.DriverError } 27 | func (c *testcase) GoListModOutput() map[string]string { return c.ListModOutput } 28 | func (c *testcase) GoListPkgOutput() map[string]string { return c.ListPkgOutput } 29 | func (c *testcase) GoGraphOutput() string { return "" } 30 | 31 | func TestModuleInformationRetrieval(t *testing.T) { 32 | cwd, err := os.Getwd() 33 | require.NoError(t, err) 34 | 35 | // Prepend the testdata directory to the path so we use the fake "go" script. 36 | err = os.Setenv("PATH", filepath.Join(cwd, "..", "internal", "testutil")+":"+os.Getenv("PATH")) 37 | require.NoError(t, err) 38 | 39 | files, err := ioutil.ReadDir(filepath.Join(cwd, "testdata")) 40 | require.NoError(t, err) 41 | 42 | for idx := range files { 43 | file := files[idx] 44 | if file.IsDir() || filepath.Ext(file.Name()) != ".yaml" { 45 | continue 46 | } 47 | 48 | testname := strings.TrimSuffix(file.Name(), ".yaml") 49 | t.Run(testname, func(t *testing.T) { 50 | testDefinition := &testcase{} 51 | testDir := testutil.SetupTestModule(t, filepath.Join(cwd, "testdata", file.Name()), testDefinition) 52 | 53 | log := testutil.TestLogger(t) 54 | 55 | main, modules, testErr := GetDependencies(log.Log(), testDir) 56 | if testDefinition.ExpectedError { 57 | assert.Error(t, testErr) 58 | } else { 59 | require.NoError(t, testErr) 60 | assert.Equal(t, testDefinition.ExpectedMain, main) 61 | assert.Equal(t, testDefinition.ExpectedModules, modules) 62 | } 63 | 64 | main, modules, testErr = GetDependenciesWithUpdates(log.Log(), testDir) 65 | if testDefinition.ExpectedError { 66 | assert.Error(t, testErr) 67 | } else { 68 | require.NoError(t, testErr) 69 | assert.Equal(t, testDefinition.ExpectedMain, main) 70 | assert.Equal(t, testDefinition.ExpectedModules, modules) 71 | } 72 | 73 | if !testDefinition.ExpectedError { 74 | main, testErr = GetModule(log.Log(), testDir, testDefinition.ExpectedMain.Path) 75 | require.NoError(t, testErr) 76 | assert.Equal(t, testDefinition.ExpectedMain, main) 77 | 78 | main, testErr = GetModuleWithUpdate(log.Log(), testDir, testDefinition.ExpectedMain.Path) 79 | require.NoError(t, testErr) 80 | assert.Equal(t, testDefinition.ExpectedMain, main) 81 | } 82 | }) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /internal/modules/packages.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | // PackageInfo represents the data returned by 'go list --json' for a Go package. It's content is 4 | // extracted directly from the Go documentation. 5 | type PackageInfo struct { 6 | Dir string // directory containing package sources 7 | ImportPath string // import path of package in dir 8 | ImportComment string // path in import comment on package statement 9 | Name string // package name 10 | Doc string // package documentation string 11 | Target string // install path 12 | Shlib string // the shared library that contains this package (only set when -linkshared) 13 | Goroot bool // is this package in the Go root? 14 | Standard bool // is this package part of the standard Go library? 15 | Stale bool // would 'go install' do anything for this package? 16 | StaleReason string // explanation for Stale==true 17 | Root string // Go root or Go path dir containing this package 18 | ConflictDir string // this directory shadows Dir in $GOPATH 19 | BinaryOnly bool // binary-only package (no longer supported) 20 | ForTest string // package is only for use in named test 21 | Export string // file containing export data (when using -export) 22 | Module *ModuleInfo // info about package's containing module, if any (can be nil) 23 | Match []string // command-line patterns matching this package 24 | DepOnly bool // package is only a dependency, not explicitly listed 25 | 26 | // Source files 27 | GoFiles []string // .go source files (excluding CgoFiles, TestGoFiles, XTestGoFiles) 28 | CgoFiles []string // .go source files that import "C" 29 | CompiledGoFiles []string // .go files presented to compiler (when using -compiled) 30 | IgnoredGoFiles []string // .go source files ignored due to build constraints 31 | CFiles []string // .c source files 32 | CXXFiles []string // .cc, .cxx and .cpp source files 33 | MFiles []string // .m source files 34 | HFiles []string // .h, .hh, .hpp and .hxx source files 35 | FFiles []string // .f, .F, .for and .f90 Fortran source files 36 | SFiles []string // .s source files 37 | SwigFiles []string // .swig files 38 | SwigCXXFiles []string // .swigcxx files 39 | SysoFiles []string // .syso object files to add to archive 40 | TestGoFiles []string // _test.go files in package 41 | XTestGoFiles []string // _test.go files outside package 42 | 43 | // Cgo directives 44 | CgoCFLAGS []string // cgo: flags for C compiler 45 | CgoCPPFLAGS []string // cgo: flags for C preprocessor 46 | CgoCXXFLAGS []string // cgo: flags for C++ compiler 47 | CgoFFLAGS []string // cgo: flags for Fortran compiler 48 | CgoLDFLAGS []string // cgo: flags for linker 49 | CgoPkgConfig []string // cgo: pkg-config names 50 | 51 | // Dependency information 52 | Imports []string // import paths used by this package 53 | ImportMap map[string]string // map from source import to ImportPath (identity entries omitted) 54 | Deps []string // all (recursively) imported dependencies 55 | TestImports []string // imports from TestGoFiles 56 | XTestImports []string // imports from XTestGoFiles 57 | 58 | // Error information 59 | Incomplete bool // this package or a dependency has an error 60 | Error *PackageError // error loading package 61 | DepsErrors []*PackageError // errors loading dependencies 62 | } 63 | 64 | type PackageError struct { 65 | ImportStack []string // shortest path from package named on command line to this one 66 | Pos string // position of error (if present, file:line:col) 67 | Err string // the error itself 68 | } 69 | -------------------------------------------------------------------------------- /internal/modules/testdata/GoListError.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | driver_error: true 3 | error: true 4 | -------------------------------------------------------------------------------- /internal/modules/testdata/InvalidListOutput.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | go_list_mod_output: 3 | github.com/Helcaraxan/gomod: | 4 | This is no JSON 5 | error: true 6 | -------------------------------------------------------------------------------- /internal/modules/testdata/LoadError.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | go_list_mod_output: 3 | test-module: | 4 | { 5 | "Error": { 6 | "Err": "could not load module" 7 | } 8 | } 9 | error: true 10 | -------------------------------------------------------------------------------- /internal/modules/testdata/LoadErrorNotFatal.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | go_list_mod_output: 3 | github.com/Helcaraxan/gomod: | 4 | { 5 | "Path": "github.com/Helcaraxan/gomod", 6 | "Main": true, 7 | "Dir": "/Users/duco/go/src/github.com/Helcaraxan/gomod", 8 | "GoMod": "/Users/duco/go/src/github.com/Helcaraxan/gomod/go.mod" 9 | } 10 | { 11 | "Error": { 12 | "Err": "could not load module" 13 | } 14 | } 15 | main: 16 | path: "github.com/Helcaraxan/gomod" 17 | main: true 18 | dir: "/Users/duco/go/src/github.com/Helcaraxan/gomod" 19 | gomod: "/Users/duco/go/src/github.com/Helcaraxan/gomod/go.mod" 20 | modules: 21 | "github.com/Helcaraxan/gomod": 22 | path: "github.com/Helcaraxan/gomod" 23 | main: true 24 | dir: "/Users/duco/go/src/github.com/Helcaraxan/gomod" 25 | gomod: "/Users/duco/go/src/github.com/Helcaraxan/gomod/go.mod" 26 | -------------------------------------------------------------------------------- /internal/modules/testdata/NoDependencies.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | go_list_mod_output: 3 | github.com/Helcaraxan/gomod: | 4 | { 5 | "Path": "github.com/Helcaraxan/gomod", 6 | "Main": true, 7 | "Dir": "/Users/duco/go/src/github.com/Helcaraxan/gomod", 8 | "GoMod": "/Users/duco/go/src/github.com/Helcaraxan/gomod/go.mod" 9 | } 10 | main: 11 | path: "github.com/Helcaraxan/gomod" 12 | main: true 13 | dir: "/Users/duco/go/src/github.com/Helcaraxan/gomod" 14 | gomod: "/Users/duco/go/src/github.com/Helcaraxan/gomod/go.mod" 15 | modules: 16 | "github.com/Helcaraxan/gomod": 17 | path: "github.com/Helcaraxan/gomod" 18 | main: true 19 | dir: "/Users/duco/go/src/github.com/Helcaraxan/gomod" 20 | gomod: "/Users/duco/go/src/github.com/Helcaraxan/gomod/go.mod" 21 | -------------------------------------------------------------------------------- /internal/modules/testdata/NoModule.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | go_list_mod_output: 3 | error: true 4 | -------------------------------------------------------------------------------- /internal/modules/testdata/ReplacedDependency.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | go_list_mod_output: 3 | github.com/Helcaraxan/gomod: | 4 | { 5 | "Path": "github.com/Helcaraxan/gomod", 6 | "Main": true, 7 | "Dir": "/Users/duco/go/src/github.com/Helcaraxan/gomod", 8 | "GoMod": "/Users/duco/go/src/github.com/Helcaraxan/gomod/go.mod" 9 | } 10 | github.com/foo/bar: | 11 | { 12 | "Path": "github.com/foo/bar", 13 | "Replace": { 14 | "Path": "../test_bar", 15 | "GoMod": "/foo/test_bar/go.mod" 16 | }, 17 | "Dir": "/foo/bar", 18 | "GoMod": "/foo/bar/go.mod" 19 | } 20 | main: 21 | path: "github.com/Helcaraxan/gomod" 22 | main: true 23 | dir: "/Users/duco/go/src/github.com/Helcaraxan/gomod" 24 | gomod: "/Users/duco/go/src/github.com/Helcaraxan/gomod/go.mod" 25 | modules: 26 | "github.com/Helcaraxan/gomod": 27 | path: "github.com/Helcaraxan/gomod" 28 | main: true 29 | dir: "/Users/duco/go/src/github.com/Helcaraxan/gomod" 30 | gomod: "/Users/duco/go/src/github.com/Helcaraxan/gomod/go.mod" 31 | "github.com/foo/bar": 32 | path: "github.com/foo/bar" 33 | replace: 34 | path: "../test_bar" 35 | gomod: "/foo/test_bar/go.mod" 36 | dir: "/foo/bar" 37 | gomod: "/foo/bar/go.mod" 38 | "../test_bar": 39 | path: "github.com/foo/bar" 40 | replace: 41 | path: "../test_bar" 42 | gomod: "/foo/test_bar/go.mod" 43 | dir: "/foo/bar" 44 | gomod: "/foo/bar/go.mod" 45 | -------------------------------------------------------------------------------- /internal/parsers/style.go: -------------------------------------------------------------------------------- 1 | package parsers 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | 7 | "go.uber.org/zap" 8 | 9 | "github.com/Helcaraxan/gomod/internal/logger" 10 | "github.com/Helcaraxan/gomod/internal/printer" 11 | ) 12 | 13 | func ParseStyleConfiguration(log *logger.Logger, config string) (*printer.StyleOptions, error) { 14 | styleOptions := &printer.StyleOptions{} 15 | for _, setting := range strings.Split(config, ",") { 16 | if setting == "" { 17 | continue 18 | } 19 | 20 | configKey := setting 21 | var configValue string 22 | if valueIdx := strings.Index(setting, "="); valueIdx >= 0 { 23 | configKey = setting[:valueIdx] 24 | configValue = setting[valueIdx+1:] 25 | } 26 | configKey = strings.ToLower(strings.TrimSpace(configKey)) 27 | configValue = strings.ToLower(strings.TrimSpace(configValue)) 28 | 29 | var err error 30 | switch configKey { 31 | case "scale_nodes": 32 | err = parseStyleScaleNodes(log, styleOptions, configValue) 33 | case "cluster": 34 | err = parseStyleCluster(log, styleOptions, configValue) 35 | default: 36 | log.Error("Skipping unknown style option.", zap.String("option", configKey)) 37 | err = errors.New("invalid config") 38 | } 39 | if err != nil { 40 | return nil, err 41 | } 42 | } 43 | return styleOptions, nil 44 | } 45 | 46 | func parseStyleScaleNodes(log *logger.Logger, styleOptions *printer.StyleOptions, raw string) error { 47 | switch strings.ToLower(raw) { 48 | case "", "true", "on", "yes": 49 | styleOptions.ScaleNodes = true 50 | case "false", "off", "no": 51 | styleOptions.ScaleNodes = false 52 | default: 53 | log.Error("Could not set 'scale_nodes' style. Accepted values are 'true' and 'false'.", zap.String("value", raw)) 54 | return errors.New("invalid 'scale_nodes' value") 55 | } 56 | return nil 57 | } 58 | 59 | func parseStyleCluster(log *logger.Logger, styleOptions *printer.StyleOptions, raw string) error { 60 | switch strings.ToLower(raw) { 61 | case "off", "false", "no": 62 | styleOptions.Cluster = printer.Off 63 | case "", "shared", "on", "true", "yes": 64 | styleOptions.Cluster = printer.Shared 65 | case "full": 66 | styleOptions.Cluster = printer.Full 67 | default: 68 | log.Error("Could not set 'cluster' style. Accepted values are 'off', 'shared' and 'full'.", zap.String("value", raw)) 69 | return errors.New("invalid 'cluster' value") 70 | } 71 | return nil 72 | } 73 | -------------------------------------------------------------------------------- /internal/parsers/style_test.go: -------------------------------------------------------------------------------- 1 | package parsers 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | 9 | "github.com/Helcaraxan/gomod/internal/printer" 10 | "github.com/Helcaraxan/gomod/internal/testutil" 11 | ) 12 | 13 | func TestVisualConfig(t *testing.T) { 14 | testcases := map[string]struct { 15 | optionValue string 16 | expectedConfig *printer.StyleOptions 17 | expectedError bool 18 | }{ 19 | "Empty": { 20 | optionValue: "", 21 | expectedConfig: &printer.StyleOptions{}, 22 | }, 23 | "ScaleNodesFalse": { 24 | optionValue: "scale_nodes=false", 25 | expectedConfig: &printer.StyleOptions{ScaleNodes: false}, 26 | }, 27 | "ScaleNodesNo": { 28 | optionValue: "scale_nodes=no", 29 | expectedConfig: &printer.StyleOptions{ScaleNodes: false}, 30 | }, 31 | "ScaleNodesOff": { 32 | optionValue: "scale_nodes=off", 33 | expectedConfig: &printer.StyleOptions{ScaleNodes: false}, 34 | }, 35 | "ScaleNodesEmpty": { 36 | optionValue: "scale_nodes", 37 | expectedConfig: &printer.StyleOptions{ScaleNodes: true}, 38 | }, 39 | "ScaleNodesTrue": { 40 | optionValue: "scale_nodes=true", 41 | expectedConfig: &printer.StyleOptions{ScaleNodes: true}, 42 | }, 43 | "ScaleNodesYes": { 44 | optionValue: "scale_nodes=yes", 45 | expectedConfig: &printer.StyleOptions{ScaleNodes: true}, 46 | }, 47 | "ScaleNodesOn": { 48 | optionValue: "scale_nodes=on", 49 | expectedConfig: &printer.StyleOptions{ScaleNodes: true}, 50 | }, 51 | "ClusterFalse": { 52 | optionValue: "cluster=false", 53 | expectedConfig: &printer.StyleOptions{Cluster: printer.Off}, 54 | }, 55 | "ClusterNo": { 56 | optionValue: "cluster=no", 57 | expectedConfig: &printer.StyleOptions{Cluster: printer.Off}, 58 | }, 59 | "ClusterOff": { 60 | optionValue: "cluster=off", 61 | expectedConfig: &printer.StyleOptions{Cluster: printer.Off}, 62 | }, 63 | "ClusterEmpty": { 64 | optionValue: "cluster", 65 | expectedConfig: &printer.StyleOptions{Cluster: printer.Shared}, 66 | }, 67 | "ClusterShared": { 68 | optionValue: "cluster=shared", 69 | expectedConfig: &printer.StyleOptions{Cluster: printer.Shared}, 70 | }, 71 | "ClusterTrue": { 72 | optionValue: "cluster=true", 73 | expectedConfig: &printer.StyleOptions{Cluster: printer.Shared}, 74 | }, 75 | "ClusterOn": { 76 | optionValue: "cluster=on", 77 | expectedConfig: &printer.StyleOptions{Cluster: printer.Shared}, 78 | }, 79 | "ClusterYes": { 80 | optionValue: "cluster=yes", 81 | expectedConfig: &printer.StyleOptions{Cluster: printer.Shared}, 82 | }, 83 | "ClusterFull": { 84 | optionValue: "cluster=full", 85 | expectedConfig: &printer.StyleOptions{Cluster: printer.Full}, 86 | }, 87 | "AllConfigsSimple": { 88 | optionValue: "cluster=true,scale_nodes=true", 89 | expectedConfig: &printer.StyleOptions{ 90 | Cluster: printer.Shared, 91 | ScaleNodes: true, 92 | }, 93 | }, 94 | "AllConfigsComplex": { 95 | optionValue: "cluster=True , scale_nodes = tRuE", 96 | expectedConfig: &printer.StyleOptions{ 97 | Cluster: printer.Shared, 98 | ScaleNodes: true, 99 | }, 100 | }, 101 | "UnknownConfig": { 102 | optionValue: "foo", 103 | expectedError: true, 104 | }, 105 | "UnknownScaleNodeValue": { 106 | optionValue: "scale_nodes=foo", 107 | expectedError: true, 108 | }, 109 | "UnknownClusterValue": { 110 | optionValue: "cluster=foo", 111 | expectedError: true, 112 | }, 113 | } 114 | 115 | for name := range testcases { 116 | testcase := testcases[name] 117 | t.Run(name, func(t *testing.T) { 118 | log := testutil.TestLogger(t) 119 | config, err := ParseStyleConfiguration(log.Log(), testcase.optionValue) 120 | if testcase.expectedError { 121 | assert.Error(t, err) 122 | } else { 123 | require.NoError(t, err) 124 | assert.Equal(t, testcase.expectedConfig, config) 125 | } 126 | }) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /internal/printer/clustering.go: -------------------------------------------------------------------------------- 1 | package printer 2 | 3 | import ( 4 | "math" 5 | "sort" 6 | "strings" 7 | "unicode" 8 | 9 | "github.com/Helcaraxan/gomod/internal/graph" 10 | ) 11 | 12 | func computeGraphClusters(g *graph.HierarchicalDigraph, config *PrintConfig) *graphClusters { 13 | graphClusters := &graphClusters{ 14 | graph: g, 15 | level: config.Granularity, 16 | clusterMap: map[string]*graphCluster{}, 17 | cachedDepthMaps: map[string]map[string]int{}, 18 | } 19 | 20 | hashToCluster := map[string]*graphCluster{} 21 | for _, node := range g.GetLevel(int(config.Granularity)).List() { 22 | clusterHash := computeClusterHash(config, node) 23 | cluster := hashToCluster[clusterHash] 24 | if cluster == nil { 25 | cluster = newGraphCluster(clusterHash) 26 | hashToCluster[clusterHash] = cluster 27 | graphClusters.clusterList = append(graphClusters.clusterList, cluster) 28 | } 29 | cluster.members = append(cluster.members, node) 30 | graphClusters.clusterMap[node.Hash()] = cluster 31 | } 32 | 33 | // Ensure determinism by sorting the nodes in each cluster. The order that is used puts nodes 34 | // with no dependencies first and those with at least one last, the rest of the ordering is done 35 | // by alphabetical order. 36 | for hash := range hashToCluster { 37 | cluster := hashToCluster[hash] 38 | sort.Slice(cluster.members, func(i int, j int) bool { 39 | hasDepsI := cluster.members[i].Successors().Len() > 0 40 | hasDepsJ := cluster.members[j].Successors().Len() > 0 41 | if (hasDepsI && !hasDepsJ) || (!hasDepsI && hasDepsJ) { 42 | return hasDepsJ 43 | } 44 | return cluster.members[i].Name() < cluster.members[j].Name() 45 | }) 46 | } 47 | sort.Slice(graphClusters.clusterList, func(i int, j int) bool { 48 | return graphClusters.clusterList[i].hash < graphClusters.clusterList[j].hash 49 | }) 50 | return graphClusters 51 | } 52 | 53 | func computeClusterHash(config *PrintConfig, node graph.Node) string { 54 | var hashElements []string 55 | for _, pred := range node.Predecessors().List() { 56 | hashElements = append(hashElements, nodeNameToHash(pred.Name())) 57 | } 58 | sort.Strings(hashElements) 59 | hash := strings.Join(hashElements, "_") 60 | 61 | // Depending on the configuration we need to generate more or less unique cluster names. 62 | if config.Style == nil || config.Style.Cluster == Off || (config.Style.Cluster == Shared && node.Predecessors().Len() > 1) { 63 | hash = node.Name() + "_from_" + hash 64 | } 65 | return hash 66 | } 67 | 68 | type graphClusters struct { 69 | graph *graph.HierarchicalDigraph 70 | level Level 71 | 72 | clusterMap map[string]*graphCluster 73 | clusterList []*graphCluster 74 | 75 | cachedDepthMaps map[string]map[string]int 76 | } 77 | 78 | func (c *graphClusters) clusterDepthMap(nodeHash string) map[string]int { 79 | if m, ok := c.cachedDepthMaps[nodeHash]; ok { 80 | return m 81 | } 82 | 83 | depthMap := map[string]int{} 84 | levelNodes := c.graph.GetLevel(int(c.level)) 85 | 86 | startNode, _ := levelNodes.Get(nodeHash) 87 | workStack := []graph.Node{startNode} 88 | workMap := map[string]int{nodeHash: 0} 89 | pathLength := 0 90 | for len(workStack) > 0 { 91 | pathLength++ 92 | curr := workStack[len(workStack)-1] 93 | if counter, ok := workMap[curr.Hash()]; ok && counter > 0 { // Reached leaf of the DFS or cycle detected. 94 | workStack = workStack[:len(workStack)-1] 95 | pathLength-- 96 | if counter == pathLength-1 { // Reached leaf of the DFS. 97 | delete(workMap, curr.Hash()) 98 | } 99 | continue 100 | } 101 | workMap[curr.Hash()] = pathLength 102 | 103 | currentDepth := depthMap[curr.Hash()] 104 | baseEdgeLength := c.clusterMap[curr.Hash()].getHeight() 105 | for _, pred := range curr.Predecessors().List() { 106 | predNode, _ := levelNodes.Get(pred.Hash()) 107 | edgeLength := baseEdgeLength + c.clusterMap[curr.Hash()].getDepCount()/20 // Give bonus space for larger numbers of edges. 108 | if depthMap[pred.Hash()] >= currentDepth+edgeLength { 109 | continue 110 | } 111 | depthMap[pred.Hash()] = currentDepth + edgeLength 112 | if _, ok := workMap[pred.Hash()]; !ok { // Only allow one instance of a node in the queue. 113 | workMap[pred.Hash()] = 0 114 | workStack = append(workStack, predNode) 115 | } 116 | } 117 | } 118 | c.cachedDepthMaps[nodeHash] = depthMap 119 | 120 | return depthMap 121 | } 122 | 123 | type graphCluster struct { 124 | id int 125 | hash string 126 | members []graph.Node 127 | 128 | cachedDepCount int 129 | cachedWidth int 130 | } 131 | 132 | var clusterIDCounter int 133 | 134 | func newGraphCluster(hash string) *graphCluster { 135 | clusterIDCounter++ 136 | return &graphCluster{ 137 | id: clusterIDCounter, 138 | hash: hash, 139 | cachedDepCount: -1, 140 | cachedWidth: -1, 141 | } 142 | } 143 | 144 | var alphaNumericalRange = []*unicode.RangeTable{unicode.Letter, unicode.Number} 145 | 146 | func (c *graphCluster) name() string { 147 | if len(c.members) > 1 { 148 | return "cluster_" + c.hash 149 | } 150 | return c.hash 151 | } 152 | 153 | func (c *graphCluster) getRepresentative() string { 154 | if len(c.members) == 0 { 155 | return "" 156 | } 157 | return c.members[c.getWidth()/2].Name() 158 | } 159 | 160 | func (c *graphCluster) getDepCount() int { 161 | if c.cachedDepCount >= 0 { 162 | return c.cachedDepCount 163 | } 164 | 165 | var depCount int 166 | for idx := len(c.members) - 1; idx >= 0; idx-- { 167 | if c.members[idx].Successors().Len() == 0 { 168 | break 169 | } 170 | depCount += c.members[idx].Successors().Len() 171 | } 172 | c.cachedDepCount = depCount 173 | return depCount 174 | } 175 | 176 | func (c *graphCluster) getHeight() int { 177 | width := c.getWidth() 178 | heigth := len(c.members) / width 179 | if len(c.members)%width != 0 { 180 | heigth++ 181 | } 182 | if heigth > 1 { 183 | heigth++ 184 | } 185 | return heigth 186 | } 187 | 188 | func (c *graphCluster) getWidth() int { 189 | if c.cachedWidth >= 0 { 190 | return c.cachedWidth 191 | } 192 | 193 | membersWithDeps := 1 194 | for membersWithDeps < len(c.members) && c.members[len(c.members)-1-membersWithDeps].Successors().Len() > 0 { 195 | membersWithDeps++ 196 | } 197 | 198 | clusterWidth := int(math.Floor(math.Sqrt(float64(len(c.members))))) 199 | if membersWithDeps > clusterWidth { 200 | clusterWidth = membersWithDeps 201 | } 202 | c.cachedWidth = clusterWidth 203 | return clusterWidth 204 | } 205 | 206 | func nodeNameToHash(nodeName string) string { 207 | var hash string 208 | for _, c := range nodeName { 209 | if unicode.IsOneOf(alphaNumericalRange, c) { 210 | hash += string(c) 211 | } else { 212 | hash += "_" 213 | } 214 | } 215 | return hash 216 | } 217 | -------------------------------------------------------------------------------- /internal/printer/printer.go: -------------------------------------------------------------------------------- 1 | package printer 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "os" 7 | "strings" 8 | 9 | "go.uber.org/zap" 10 | 11 | "github.com/Helcaraxan/gomod/internal/depgraph" 12 | "github.com/Helcaraxan/gomod/internal/graph" 13 | "github.com/Helcaraxan/gomod/internal/logger" 14 | "github.com/Helcaraxan/gomod/internal/util" 15 | ) 16 | 17 | type Level uint8 18 | 19 | const ( 20 | LevelModules Level = iota 21 | LevelPackages 22 | ) 23 | 24 | // PrintConfig allows for the specification of parameters that should be passed to the Print 25 | // function of a Graph. 26 | type PrintConfig struct { 27 | // Logger that should be used to show progress while printing the Graph. 28 | Log *logger.Logger 29 | 30 | // Which level of granularity to print the graph at (modules, packages). 31 | Granularity Level 32 | 33 | // Annotate edges and nodes with their respective versions. 34 | Annotate bool 35 | // Path at which the printed version of the Graph should be stored. If set to a nil-string a 36 | // temporary file will be created. 37 | OutputPath string 38 | // Options for generating a visual representation of the Graph. If the field is non-nil, print 39 | // out an image file using GraphViz, if false print out the graph in DOT format. 40 | Style *StyleOptions 41 | } 42 | 43 | type StyleOptions struct { 44 | // Scale nodes according to the number of their successors and predecssors. 45 | ScaleNodes bool 46 | // Level at which to cluster nodes in the printed graph. This can be very beneficial for larger 47 | // dependency graphs that might be unreadable with the default settings. 48 | Cluster ClusterLevel 49 | } 50 | 51 | // Level at which to performing clustering when generating the image of the 52 | // dependency graph. 53 | type ClusterLevel int 54 | 55 | const ( 56 | // No clustering. Each node is printed as is. 57 | Off ClusterLevel = iota 58 | // Cluster nodes that have the same parent. 59 | Parent 60 | // Cluster nodes that all have the same, unique, predecessor in the graph. 61 | Shared 62 | // Cluster nodes that all have the same (group of) predecessor(s) in the graph. 63 | Full 64 | ) 65 | 66 | // Print takes in a PrintConfig struct and dumps the content of a HierarchicalDigraph instance 67 | // according to parameters. 68 | func Print(g *graph.HierarchicalDigraph, config *PrintConfig) error { 69 | var err error 70 | out := os.Stdout 71 | if len(config.OutputPath) > 0 { 72 | if out, err = util.PrepareOutputPath(config.Log, config.OutputPath); err != nil { 73 | return err 74 | } 75 | defer func() { 76 | _ = out.Close() 77 | }() 78 | config.Log.Debug("Writing DOT graph.", zap.String("path", config.OutputPath)) 79 | } else { 80 | config.Log.Debug("Writing DOT graph to terminal.") 81 | } 82 | 83 | fileContent := []string{ 84 | "strict digraph {", 85 | } 86 | fileContent = append(fileContent, determineGlobalOptions(g, config)...) 87 | 88 | clusters := computeGraphClusters(g, config) 89 | for _, cluster := range clusters.clusterList { 90 | fileContent = append(fileContent, printClusterToDot(cluster, config)) 91 | } 92 | 93 | for _, node := range g.GetLevel(int(config.Granularity)).List() { 94 | fileContent = append(fileContent, printEdgesToDot(config, node, clusters)...) 95 | } 96 | 97 | fileContent = append(fileContent, "}") 98 | 99 | if _, err = out.WriteString(strings.Join(fileContent, "\n") + "\n"); err != nil { 100 | config.Log.Error("Failed to write DOT file.", zap.Error(err)) 101 | return fmt.Errorf("could not write to %q", out.Name()) 102 | } 103 | return nil 104 | } 105 | 106 | func determineGlobalOptions(g *graph.HierarchicalDigraph, config *PrintConfig) []string { 107 | globalOptions := []string{ 108 | " node [shape=box,style=\"rounded,filled\"]", 109 | " start=0", // Needed for placement determinism. 110 | } 111 | 112 | if config.Annotate { 113 | globalOptions = append(globalOptions, " concentrate=true") 114 | } else { 115 | // Unfortunately we cannot use the "concentrate" option with 'ortho' splines as it leads to segfaults on large graphs. 116 | globalOptions = append( 117 | globalOptions, 118 | " splines=ortho", // By far the most readable form of splines on larger graphs but incompatible with annotations. 119 | ) 120 | } 121 | 122 | if config.Style != nil { 123 | if config.Style.Cluster > Off { 124 | globalOptions = append( 125 | globalOptions, 126 | " graph [style=rounded]", 127 | " compound=true", // Needed for edges targeted at subgraphs. 128 | ) 129 | } 130 | if config.Style.ScaleNodes { 131 | rankSep := math.Log10(float64(g.GetLevel(int(config.Granularity)).Len())) - 1 132 | if rankSep < 0.3 { 133 | rankSep = 0.3 134 | } 135 | globalOptions = append(globalOptions, fmt.Sprintf(" ranksep=%.2f", rankSep)) 136 | } 137 | } 138 | 139 | return globalOptions 140 | } 141 | 142 | func printClusterToDot(cluster *graphCluster, config *PrintConfig) string { 143 | if len(cluster.members) == 0 { 144 | config.Log.Warn("Found an empty node cluster associated with.", zap.String("cluster", cluster.name()), zap.String("hash", cluster.hash)) 145 | return "" 146 | } else if len(cluster.members) == 1 { 147 | return printNodeToDot(config, cluster.members[0]) 148 | } 149 | 150 | dot := " subgraph " + cluster.name() + "{\n" 151 | for _, node := range cluster.members { 152 | dot += " " + printNodeToDot(config, node) + "\n" 153 | } 154 | 155 | // Print invisible nodes and edges that help node placement by forcing a grid layout. 156 | dot += " // The nodes and edges part of this subgraph defined below are only used to\n" 157 | dot += " // improve node placement but do not reflect actual dependencies.\n" 158 | dot += " node [style=invis]\n" 159 | dot += " edge [style=invis,minlen=1]\n" 160 | dot += " graph [color=blue]\n" //nolint:misspell 161 | 162 | rowSize := cluster.getWidth() 163 | firstRowSize := len(cluster.members) % rowSize 164 | firstRowOffset := (rowSize - firstRowSize) / 2 165 | if firstRowSize > 0 { 166 | for idx := 0; idx < firstRowOffset; idx++ { 167 | dot += fmt.Sprintf(" \"%s_%d\"\n", cluster.name(), idx) 168 | dot += fmt.Sprintf(" \"%s_%d\" -> \"%s\"\n", cluster.name(), idx, cluster.members[idx+firstRowSize].Name()) 169 | } 170 | for idx := firstRowOffset + firstRowSize; idx < rowSize; idx++ { 171 | dot += fmt.Sprintf(" \"%s_%d\"\n", cluster.name(), idx) 172 | dot += fmt.Sprintf(" \"%s_%d\" -> \"%s\"\n", cluster.name(), idx, cluster.members[idx+firstRowSize].Name()) 173 | } 174 | } 175 | for idx := 0; idx < firstRowSize; idx++ { 176 | dot += fmt.Sprintf(" \"%s\" -> \"%s\"\n", cluster.members[idx].Name(), cluster.members[idx+firstRowOffset+firstRowSize].Name()) 177 | } 178 | for idx := firstRowSize; idx < len(cluster.members); idx++ { 179 | if idx+rowSize < len(cluster.members) { 180 | dot += fmt.Sprintf(" \"%s\" -> \"%s\"\n", cluster.members[idx].Name(), cluster.members[idx+rowSize].Name()) 181 | } 182 | } 183 | return dot + " }" 184 | } 185 | 186 | type annotated interface { 187 | NodeAttributes(annotate bool) []string 188 | EdgeAttributes(target graph.Node, annotate bool) []string 189 | } 190 | 191 | var ( 192 | _ annotated = &depgraph.Module{} 193 | _ annotated = &depgraph.Package{} 194 | ) 195 | 196 | func printNodeToDot(config *PrintConfig, node graph.Node) string { 197 | var nodeOptions []string 198 | if config.Style != nil && config.Style.ScaleNodes { 199 | scaling := math.Log2(float64(node.Predecessors().Len()+node.Successors().Len())) / 5 200 | if scaling < 0.1 { 201 | scaling = 0.1 202 | } 203 | nodeOptions = append(nodeOptions, fmt.Sprintf("width=%.2f,height=%.2f", 5*scaling, scaling)) 204 | } 205 | 206 | if a, ok := node.(annotated); ok { 207 | nodeOptions = append(nodeOptions, a.NodeAttributes(config.Annotate)...) 208 | } 209 | 210 | dot := " \"" + node.Name() + "\"" 211 | if len(nodeOptions) > 0 { 212 | dot += " [" + strings.Join(nodeOptions, ",") + "]" 213 | } 214 | return dot 215 | } 216 | 217 | func printEdgesToDot(config *PrintConfig, node graph.Node, clusters *graphClusters) []string { 218 | clustersReached := map[int]struct{}{} 219 | 220 | var dots []string 221 | for _, dep := range node.Successors().List() { 222 | cluster, ok := clusters.clusterMap[dep.Hash()] 223 | if !ok { 224 | config.Log.Error("No cluster reference found for dependency.", zap.String("node", node.Hash()), zap.String("dep", dep.Hash())) 225 | continue 226 | } else if _, ok = clustersReached[cluster.id]; ok { 227 | continue 228 | } 229 | clustersReached[cluster.id] = struct{}{} 230 | 231 | target := dep.Name() 232 | var edgeAnnotations []string 233 | if minLength := clusters.clusterDepthMap(dep.Hash())[node.Hash()]; minLength > 1 { 234 | edgeAnnotations = append(edgeAnnotations, fmt.Sprintf("minlen=%d", minLength)) 235 | } 236 | 237 | annotate := config.Annotate 238 | if len(cluster.members) > 1 { 239 | annotate = false 240 | target = cluster.getRepresentative() 241 | edgeAnnotations = append(edgeAnnotations, "lhead=\""+cluster.name()+"\"") 242 | } 243 | 244 | if a, ok := node.(annotated); ok { 245 | edgeAnnotations = append(edgeAnnotations, a.EdgeAttributes(dep, annotate)...) 246 | } 247 | 248 | dot := " \"" + node.Name() + "\" -> \"" + target + "\"" 249 | if len(edgeAnnotations) > 0 { 250 | dot += " [" + strings.Join(edgeAnnotations, ",") + "]" 251 | } 252 | dots = append(dots, dot) 253 | } 254 | return dots 255 | } 256 | -------------------------------------------------------------------------------- /internal/query/grammar.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type Expr interface { 9 | String() string 10 | Pos() Position 11 | _expr() 12 | } 13 | 14 | type ValueExpr interface { 15 | Expr 16 | _valueExpr() 17 | } 18 | 19 | type ExprBool struct { 20 | v bool 21 | p Position 22 | } 23 | type ExprInteger struct { 24 | v int 25 | p Position 26 | } 27 | type ExprString struct { 28 | v string 29 | p Position 30 | } 31 | 32 | func (e *ExprBool) Value() bool { return e.v } 33 | func (e *ExprInteger) Value() int { return e.v } 34 | func (e *ExprString) Value() string { return e.v } 35 | func (e *ExprBool) String() string { return fmt.Sprintf("%v", e.v) } 36 | func (e *ExprInteger) String() string { return fmt.Sprintf("%v", e.v) } 37 | func (e *ExprString) String() string { return fmt.Sprintf("%v", e.v) } 38 | func (e *ExprBool) Pos() Position { return e.p } 39 | func (e *ExprInteger) Pos() Position { return e.p } 40 | func (e *ExprString) Pos() Position { return e.p } 41 | func (e *ExprBool) _expr() {} 42 | func (e *ExprInteger) _expr() {} 43 | func (e *ExprString) _expr() {} 44 | func (e *ExprBool) _valueExpr() {} 45 | func (e *ExprInteger) _valueExpr() {} 46 | func (e *ExprString) _valueExpr() {} 47 | 48 | type BinaryExpr interface { 49 | Expr 50 | Operands() *BinaryOperands 51 | } 52 | type BinaryOperands struct { 53 | LHS Expr 54 | RHS Expr 55 | } 56 | 57 | type ExprDelta struct { 58 | BinaryOperands 59 | p Position 60 | } 61 | type ExprIntersect struct { 62 | BinaryOperands 63 | p Position 64 | } 65 | type ExprSubtract struct { 66 | BinaryOperands 67 | p Position 68 | } 69 | type ExprUnion struct { 70 | BinaryOperands 71 | p Position 72 | } 73 | 74 | func (e *ExprDelta) Operands() *BinaryOperands { return &e.BinaryOperands } 75 | func (e *ExprIntersect) Operands() *BinaryOperands { return &e.BinaryOperands } 76 | func (e *ExprSubtract) Operands() *BinaryOperands { return &e.BinaryOperands } 77 | func (e *ExprUnion) Operands() *BinaryOperands { return &e.BinaryOperands } 78 | func (e *ExprDelta) String() string { return fmt.Sprintf("(%v delta %v)", e.LHS, e.RHS) } 79 | func (e *ExprIntersect) String() string { return fmt.Sprintf("(%v inter %v)", e.LHS, e.RHS) } 80 | func (e *ExprSubtract) String() string { return fmt.Sprintf("(%v - %v)", e.LHS, e.RHS) } 81 | func (e *ExprUnion) String() string { return fmt.Sprintf("(%v + %v)", e.LHS, e.RHS) } 82 | func (e *ExprDelta) Pos() Position { return e.p } 83 | func (e *ExprIntersect) Pos() Position { return e.p } 84 | func (e *ExprSubtract) Pos() Position { return e.p } 85 | func (e *ExprUnion) Pos() Position { return e.p } 86 | func (e *ExprDelta) _expr() {} 87 | func (e *ExprIntersect) _expr() {} 88 | func (e *ExprSubtract) _expr() {} 89 | func (e *ExprUnion) _expr() {} 90 | 91 | type FuncExpr interface { 92 | Expr 93 | Name() string 94 | Args() ArgsListExpr 95 | } 96 | 97 | type ExprFunc struct { 98 | name string 99 | args ArgsListExpr 100 | p Position 101 | } 102 | 103 | func (e *ExprFunc) Name() string { return e.name } 104 | func (e *ExprFunc) Args() ArgsListExpr { return e.args } 105 | func (e *ExprFunc) String() string { return fmt.Sprintf("%s(%v)", e.name, e.args) } 106 | func (e *ExprFunc) Pos() Position { return e.p } 107 | func (e *ExprFunc) _expr() {} 108 | 109 | type ArgsListExpr interface { 110 | Expr 111 | Args() []Expr 112 | } 113 | 114 | type ExprArgsList struct { 115 | values []Expr 116 | p Position 117 | } 118 | 119 | func (e *ExprArgsList) Args() []Expr { return e.values } 120 | func (e *ExprArgsList) String() string { 121 | var strArgs []string 122 | for _, arg := range e.values { 123 | strArgs = append(strArgs, arg.String()) 124 | } 125 | return fmt.Sprintf("[%s]", strings.Join(strArgs, ", ")) 126 | } 127 | func (e *ExprArgsList) Pos() Position { return e.p } 128 | func (e *ExprArgsList) _expr() {} 129 | 130 | var ( 131 | _ ValueExpr = &ExprBool{} 132 | _ ValueExpr = &ExprInteger{} 133 | _ ValueExpr = &ExprString{} 134 | 135 | _ BinaryExpr = &ExprSubtract{} 136 | _ BinaryExpr = &ExprUnion{} 137 | _ BinaryExpr = &ExprIntersect{} 138 | _ BinaryExpr = &ExprDelta{} 139 | 140 | _ FuncExpr = &ExprFunc{} 141 | 142 | _ ArgsListExpr = &ExprArgsList{} 143 | ) 144 | -------------------------------------------------------------------------------- /internal/query/parser_test.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | 10 | "github.com/Helcaraxan/gomod/internal/testutil" 11 | ) 12 | 13 | func TestParser(t *testing.T) { 14 | t.Parallel() 15 | 16 | testcases := map[string]struct { 17 | input string 18 | expectedExpr Expr 19 | }{ 20 | "SimpleString": { 21 | input: "foo/bar", 22 | expectedExpr: &ExprString{v: "foo/bar"}, 23 | }, 24 | "SimpleOperator": { 25 | input: "foo/bar + dead/beef", 26 | expectedExpr: &ExprUnion{BinaryOperands: BinaryOperands{ 27 | LHS: &ExprString{v: "foo/bar"}, 28 | RHS: &ExprString{v: "dead/beef"}, 29 | }}, 30 | }, 31 | "MultipleOperators": { 32 | input: "foo - bar delta dead + beef inter null", 33 | expectedExpr: &ExprSubtract{BinaryOperands: BinaryOperands{ 34 | LHS: &ExprString{v: "foo"}, 35 | RHS: &ExprUnion{BinaryOperands: BinaryOperands{ 36 | LHS: &ExprDelta{BinaryOperands: BinaryOperands{ 37 | LHS: &ExprString{v: "bar"}, 38 | RHS: &ExprString{v: "dead"}, 39 | }}, 40 | RHS: &ExprIntersect{BinaryOperands: BinaryOperands{ 41 | LHS: &ExprString{v: "beef"}, 42 | RHS: &ExprString{v: "null"}, 43 | }}, 44 | }}, 45 | }}, 46 | }, 47 | "OperatorsAndParenthesises": { 48 | input: "foo + bar delta (dead - beef) inter null", 49 | expectedExpr: &ExprUnion{BinaryOperands: BinaryOperands{ 50 | LHS: &ExprString{v: "foo"}, 51 | RHS: &ExprIntersect{BinaryOperands: BinaryOperands{ 52 | LHS: &ExprDelta{BinaryOperands: BinaryOperands{ 53 | LHS: &ExprString{v: "bar"}, 54 | RHS: &ExprSubtract{BinaryOperands: BinaryOperands{ 55 | LHS: &ExprString{v: "dead"}, 56 | RHS: &ExprString{v: "beef"}, 57 | }}, 58 | }}, 59 | RHS: &ExprString{v: "null"}, 60 | }}, 61 | }}, 62 | }, 63 | "SimpleFuncCallOneArg": { 64 | input: "deps(foo)", 65 | expectedExpr: &ExprFunc{ 66 | name: "deps", 67 | args: &ExprArgsList{values: []Expr{ 68 | &ExprString{v: "foo"}, 69 | }}, 70 | }, 71 | }, 72 | "SimpleFuncCallTwoArgs": { 73 | input: "deps(foo, 7)", 74 | expectedExpr: &ExprFunc{ 75 | name: "deps", 76 | args: &ExprArgsList{values: []Expr{ 77 | &ExprString{v: "foo"}, 78 | &ExprInteger{v: 7}, 79 | }}, 80 | }, 81 | }, 82 | "SimpleFuncCallThreeArgs": { 83 | input: "deps(foo, 42, true)", 84 | expectedExpr: &ExprFunc{ 85 | name: "deps", 86 | args: &ExprArgsList{values: []Expr{ 87 | &ExprString{v: "foo"}, 88 | &ExprInteger{v: 42}, 89 | &ExprBool{v: true}, 90 | }}, 91 | }, 92 | }, 93 | "NestedFuncCalls": { 94 | input: "foo(bar(test))", 95 | expectedExpr: &ExprFunc{ 96 | name: "foo", 97 | args: &ExprArgsList{values: []Expr{ 98 | &ExprFunc{ 99 | name: "bar", 100 | args: &ExprArgsList{values: []Expr{ 101 | &ExprString{v: "test"}, 102 | }}, 103 | }, 104 | }}, 105 | }, 106 | }, 107 | "ComplexFuncCall": { 108 | input: "foo((bar - dead) delta beef + null, 3, true)", 109 | expectedExpr: &ExprFunc{ 110 | name: "foo", 111 | args: &ExprArgsList{values: []Expr{ 112 | &ExprUnion{BinaryOperands: BinaryOperands{ 113 | LHS: &ExprDelta{BinaryOperands: BinaryOperands{ 114 | LHS: &ExprSubtract{BinaryOperands: BinaryOperands{ 115 | LHS: &ExprString{v: "bar"}, 116 | RHS: &ExprString{v: "dead"}, 117 | }}, 118 | RHS: &ExprString{v: "beef"}, 119 | }}, 120 | RHS: &ExprString{v: "null"}, 121 | }}, 122 | &ExprInteger{v: 3}, 123 | &ExprBool{v: true}, 124 | }}, 125 | }, 126 | }, 127 | "ComplexExpression": { 128 | input: "deps(foo) inter (rdeps(bar, 5, true) + dead) - beef", 129 | expectedExpr: &ExprSubtract{BinaryOperands: BinaryOperands{ 130 | LHS: &ExprIntersect{BinaryOperands: BinaryOperands{ 131 | LHS: &ExprFunc{ 132 | name: "deps", 133 | args: &ExprArgsList{values: []Expr{ 134 | &ExprString{v: "foo"}, 135 | }}, 136 | }, 137 | RHS: &ExprUnion{BinaryOperands: BinaryOperands{ 138 | LHS: &ExprFunc{ 139 | name: "rdeps", 140 | args: &ExprArgsList{values: []Expr{ 141 | &ExprString{v: "bar"}, 142 | &ExprInteger{v: 5}, 143 | &ExprBool{v: true}, 144 | }}, 145 | }, 146 | RHS: &ExprString{v: "dead"}, 147 | }}, 148 | }}, 149 | RHS: &ExprString{v: "beef"}, 150 | }}, 151 | }, 152 | } 153 | 154 | for name := range testcases { 155 | testcase := testcases[name] 156 | t.Run(name, func(t *testing.T) { 157 | t.Parallel() 158 | 159 | expr, err := Parse(testutil.TestLogger(t), testcase.input) 160 | require.NoError(t, err) 161 | assert.Equal(t, testcase.expectedExpr.String(), expr.String()) 162 | }) 163 | } 164 | } 165 | 166 | func TestParserErrors(t *testing.T) { 167 | testcases := map[string]struct { 168 | input string 169 | expectedErr error 170 | }{ 171 | "EmptyExpression": { 172 | input: "", 173 | expectedErr: ErrEmptyExpression, 174 | }, 175 | "EmptyFuncCall": { 176 | input: "foo()", 177 | expectedErr: ErrEmptyFuncCall, 178 | }, 179 | "EmptyParenthesis": { 180 | input: "()", 181 | expectedErr: ErrEmptyParenthesis, 182 | }, 183 | "MissingArgument": { 184 | input: "bar, foo,", 185 | expectedErr: ErrMissingArgument, 186 | }, 187 | "MissingOperator": { 188 | input: "foo bar", 189 | expectedErr: ErrMissingOperator, 190 | }, 191 | "InvalidFuncName": { 192 | input: "1(foo, 1, false)", 193 | expectedErr: ErrInvalidFuncName, 194 | }, 195 | "UnexpectedComma": { 196 | input: ",", 197 | expectedErr: ErrUnexpectedComma, 198 | }, 199 | "UnexpectedOperator": { 200 | input: "delta", 201 | expectedErr: ErrUnexpectedOperator, 202 | }, 203 | "UnexpectedParenthesis": { 204 | input: "(bar))", 205 | expectedErr: ErrUnexpectedParenthesis, 206 | }, 207 | "MissingOperand": { 208 | input: "foo union bar -", 209 | expectedErr: ErrMissingArgument, 210 | }, 211 | "InvalidOperandLHS": { 212 | input: "false inter bar", 213 | expectedErr: ErrInvalidArgument, 214 | }, 215 | "InvalidOperandRHS": { 216 | input: "foo delta 3", 217 | expectedErr: ErrInvalidArgument, 218 | }, 219 | } 220 | 221 | for name := range testcases { 222 | testcase := testcases[name] 223 | t.Run(name, func(t *testing.T) { 224 | t.Parallel() 225 | 226 | expr, err := Parse(testutil.TestLogger(t), testcase.input) 227 | assert.True(t, errors.Is(err, testcase.expectedErr), err) 228 | assert.Nil(t, expr) 229 | }) 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /internal/query/tokenizer.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "strconv" 8 | "strings" 9 | "unicode" 10 | ) 11 | 12 | var ErrUnclosedString = errors.New("missing quotes to close of a string") 13 | 14 | type unclosedStringErr struct { 15 | str string 16 | pos Position 17 | } 18 | 19 | func (e *unclosedStringErr) Error() string { 20 | return fmt.Sprintf("unclosed string at position %v: %s", e.pos, e.str) 21 | } 22 | 23 | func (e *unclosedStringErr) Unwrap() error { 24 | return ErrUnclosedString 25 | } 26 | 27 | var ErrTokenizer = errors.New("unexpected tokenizer error") 28 | 29 | type tokenizerErr struct { 30 | err error 31 | pos Position 32 | } 33 | 34 | func (e *tokenizerErr) Error() string { 35 | return fmt.Sprintf("tokenizer error at position %v: %v", e.pos, e.err) 36 | } 37 | 38 | func (e *tokenizerErr) Unwrap() error { 39 | return ErrTokenizer 40 | } 41 | 42 | type tokenizer struct { 43 | s *strings.Reader 44 | } 45 | 46 | func newTokenizer(s string) *tokenizer { 47 | return &tokenizer{s: strings.NewReader(s)} 48 | } 49 | 50 | // nolint: gocyclo 51 | func (t *tokenizer) next() (tkn token, err error) { 52 | var s tokenString 53 | var r rune 54 | var p int64 55 | 56 | for { 57 | p = t.s.Size() - int64(t.s.Len()) 58 | r, _, err = t.s.ReadRune() 59 | if err == io.EOF { 60 | return nil, io.EOF 61 | } else if err != nil { 62 | return nil, &tokenizerErr{err: err, pos: pos(p, t.s.Size()-int64(t.s.Len()))} 63 | } 64 | if !unicode.IsSpace(r) { 65 | break 66 | } 67 | } 68 | 69 | switch r { 70 | // Special characters. 71 | case '(': 72 | return &tokenParenLeft{p: pos(p, p+1)}, nil 73 | case ')': 74 | return &tokenParenRight{p: pos(p, p+1)}, nil 75 | case '-': 76 | return &tokenSubtract{p: pos(p, p+1)}, nil 77 | case '+': 78 | return &tokenUnion{p: pos(p, p+1)}, nil 79 | case ',': 80 | return &tokenComma{p: pos(p, p+1)}, nil 81 | 82 | // Quoted string. 83 | case '"', '\'': 84 | s, err = readString(t.s, string(r)) 85 | if err != nil { 86 | return nil, &tokenizerErr{err: err, pos: pos(p, t.s.Size()-int64(t.s.Len()))} 87 | } 88 | if _, _, err = t.s.ReadRune(); err == io.EOF { 89 | return nil, &unclosedStringErr{str: string(s.v), pos: pos(p, s.p.end)} 90 | } else if err != nil { 91 | return nil, &tokenizerErr{err: err, pos: pos(p, t.s.Size()-int64(t.s.Len()))} 92 | } 93 | s.p = pos(s.p.start-1, s.p.end+1) 94 | return &s, nil 95 | 96 | // String-based token. 97 | default: 98 | if err = t.s.UnreadRune(); err != nil { 99 | return nil, &tokenizerErr{err: err, pos: pos(p, t.s.Size()-int64(t.s.Len()))} 100 | } 101 | 102 | s, err := readString(t.s, "()=,\"' \t\n") 103 | if err != nil { 104 | return nil, &tokenizerErr{err: err, pos: pos(p, t.s.Size()-int64(t.s.Len()))} 105 | } 106 | 107 | switch s.v { 108 | case "true": 109 | return &tokenBoolean{p: pos(p, p+4), v: true}, nil 110 | case "false": 111 | return &tokenBoolean{p: pos(p, p+5), v: false}, nil 112 | case "minus": 113 | return &tokenSubtract{p: pos(p, p+5)}, nil 114 | case "union": 115 | return &tokenUnion{p: pos(p, p+5)}, nil 116 | case "inter": 117 | return &tokenIntersect{p: pos(p, p+5)}, nil 118 | case "delta": 119 | return &tokenDelta{p: pos(p, p+5)}, nil 120 | default: 121 | if v, intErr := strconv.Atoi(string(s.v)); intErr == nil { 122 | return &tokenInteger{ 123 | p: s.p, 124 | v: v, 125 | }, nil 126 | } 127 | return &s, nil 128 | } 129 | } 130 | } 131 | 132 | func readString(s *strings.Reader, eos string) (tokenString, error) { 133 | acc := strings.Builder{} 134 | p := s.Size() - int64(s.Len()) 135 | for { 136 | r, _, err := s.ReadRune() 137 | if err == io.EOF { 138 | return tokenString{ 139 | p: pos(p, s.Size()), 140 | v: acc.String(), 141 | }, nil 142 | } else if err != nil { 143 | return tokenString{}, err 144 | } 145 | 146 | if strings.ContainsRune(eos, r) { 147 | if err = s.UnreadRune(); err != nil { 148 | return tokenString{}, err 149 | } 150 | return tokenString{ 151 | p: pos(p, s.Size()-int64(s.Len())), 152 | v: acc.String(), 153 | }, nil 154 | } 155 | 156 | if _, err = acc.WriteRune(r); err != nil { 157 | return tokenString{}, err 158 | } 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /internal/query/tokenizer_test.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestSingleTokens(t *testing.T) { 12 | t.Parallel() 13 | 14 | testcases := map[string]struct { 15 | input string 16 | expectedToken token 17 | expectedErr error 18 | }{ 19 | "StringSimple": { 20 | input: "foo", 21 | expectedToken: &tokenString{p: pos(0, 3), v: "foo"}, 22 | expectedErr: nil, 23 | }, 24 | "StringWithInteger": { 25 | input: "42foo", 26 | expectedToken: &tokenString{p: pos(0, 5), v: "42foo"}, 27 | expectedErr: nil, 28 | }, 29 | "StringWithSpecialCharacters": { 30 | input: "foo-bar+dead", 31 | expectedToken: &tokenString{p: pos(0, 12), v: "foo-bar+dead"}, 32 | expectedErr: nil, 33 | }, 34 | "StringQuotedDouble": { 35 | input: "\"foo\"", 36 | expectedToken: &tokenString{p: pos(0, 5), v: "foo"}, 37 | expectedErr: nil, 38 | }, 39 | "StringQuotedSingle": { 40 | input: "'foo'", 41 | expectedToken: &tokenString{p: pos(0, 5), v: "foo"}, 42 | expectedErr: nil, 43 | }, 44 | "StringQuotedDoubleUnclosed": { 45 | input: "\"foo", 46 | expectedToken: nil, 47 | expectedErr: ErrUnclosedString, 48 | }, 49 | "StringQuotedSingleUnclosed": { 50 | input: "'foo", 51 | expectedToken: nil, 52 | expectedErr: ErrUnclosedString, 53 | }, 54 | "Integer": { 55 | input: "42", 56 | expectedToken: &tokenInteger{p: pos(0, 2), v: 42}, 57 | expectedErr: nil, 58 | }, 59 | "SubSign": { 60 | input: "-", 61 | expectedToken: &tokenSubtract{p: pos(0, 1)}, 62 | expectedErr: nil, 63 | }, 64 | "SubString": { 65 | input: "minus", 66 | expectedToken: &tokenSubtract{p: pos(0, 5)}, 67 | expectedErr: nil, 68 | }, 69 | "UnionSign": { 70 | input: "+", 71 | expectedToken: &tokenUnion{p: pos(0, 1)}, 72 | expectedErr: nil, 73 | }, 74 | "UnionString": { 75 | input: "union", 76 | expectedToken: &tokenUnion{p: pos(0, 5)}, 77 | expectedErr: nil, 78 | }, 79 | "Inter": { 80 | input: "inter", 81 | expectedToken: &tokenIntersect{p: pos(0, 5)}, 82 | expectedErr: nil, 83 | }, 84 | "Delta": { 85 | input: "delta", 86 | expectedToken: &tokenDelta{p: pos(0, 5)}, 87 | expectedErr: nil, 88 | }, 89 | "ParenthesisLeft": { 90 | input: "(", 91 | expectedToken: &tokenParenLeft{p: pos(0, 1)}, 92 | expectedErr: nil, 93 | }, 94 | "ParenthesisRight": { 95 | input: ")", 96 | expectedToken: &tokenParenRight{p: pos(0, 1)}, 97 | expectedErr: nil, 98 | }, 99 | "Comma": { 100 | input: ",", 101 | expectedToken: &tokenComma{p: pos(0, 1)}, 102 | expectedErr: nil, 103 | }, 104 | "True": { 105 | input: "true", 106 | expectedToken: &tokenBoolean{p: pos(0, 4), v: true}, 107 | expectedErr: nil, 108 | }, 109 | "False": { 110 | input: "false", 111 | expectedToken: &tokenBoolean{p: pos(0, 5), v: false}, 112 | expectedErr: nil, 113 | }, 114 | } 115 | 116 | for name := range testcases { 117 | testcase := testcases[name] 118 | t.Run(name, func(t *testing.T) { 119 | t.Parallel() 120 | 121 | tkn := newTokenizer(testcase.input) 122 | token, err := tkn.next() 123 | assert.Equal(t, testcase.expectedToken, token) 124 | assert.True(t, errors.Is(err, testcase.expectedErr)) 125 | 126 | if testcase.expectedErr == nil { 127 | token, err = tkn.next() 128 | assert.Nil(t, token) 129 | assert.Equal(t, io.EOF, err) 130 | } 131 | }) 132 | } 133 | } 134 | 135 | func TestTokenStream(t *testing.T) { 136 | t.Parallel() 137 | 138 | testcases := map[string]struct { 139 | input string 140 | expectedTokens []token 141 | expectedErr error 142 | }{ 143 | "ParenthesizedExpression": { 144 | input: "deps(foo)", 145 | expectedTokens: []token{ 146 | &tokenString{p: pos(0, 4), v: "deps"}, 147 | &tokenParenLeft{p: pos(4, 5)}, 148 | &tokenString{p: pos(5, 8), v: "foo"}, 149 | &tokenParenRight{p: pos(8, 9)}, 150 | }, 151 | expectedErr: io.EOF, 152 | }, 153 | "CommaSeparatedValues": { 154 | input: "foo, 42, bar", 155 | expectedTokens: []token{ 156 | &tokenString{p: pos(0, 3), v: "foo"}, 157 | &tokenComma{p: pos(3, 4)}, 158 | &tokenInteger{p: pos(5, 7), v: 42}, 159 | &tokenComma{p: pos(7, 8)}, 160 | &tokenString{p: pos(9, 12), v: "bar"}, 161 | }, 162 | expectedErr: io.EOF, 163 | }, 164 | "ParenthesizedExpressionComplex": { 165 | input: `deps("foo", 2, true) union rdeps(bar - dead/beef)`, 166 | expectedTokens: []token{ 167 | &tokenString{p: pos(0, 4), v: "deps"}, 168 | &tokenParenLeft{p: pos(4, 5)}, 169 | &tokenString{p: pos(5, 10), v: "foo"}, 170 | &tokenComma{p: pos(10, 11)}, 171 | &tokenInteger{p: pos(12, 13), v: 2}, 172 | &tokenComma{p: pos(13, 14)}, 173 | &tokenBoolean{p: pos(15, 19), v: true}, 174 | &tokenParenRight{p: pos(19, 20)}, 175 | &tokenUnion{p: pos(21, 26)}, 176 | &tokenString{p: pos(27, 32), v: "rdeps"}, 177 | &tokenParenLeft{p: pos(32, 33)}, 178 | &tokenString{p: pos(33, 36), v: "bar"}, 179 | &tokenSubtract{p: pos(37, 38)}, 180 | &tokenString{p: pos(39, 48), v: "dead/beef"}, 181 | &tokenParenRight{p: pos(48, 49)}, 182 | }, 183 | expectedErr: io.EOF, 184 | }, 185 | "UnclosedString": { 186 | input: `rdeps union( foo, "bar)`, 187 | expectedTokens: []token{ 188 | &tokenString{p: pos(0, 5), v: "rdeps"}, 189 | &tokenUnion{p: pos(6, 11)}, 190 | &tokenParenLeft{p: pos(11, 12)}, 191 | &tokenString{p: pos(13, 16), v: "foo"}, 192 | &tokenComma{p: pos(16, 17)}, 193 | }, 194 | expectedErr: ErrUnclosedString, 195 | }, 196 | } 197 | 198 | for name := range testcases { 199 | testcase := testcases[name] 200 | t.Run(name, func(t *testing.T) { 201 | t.Parallel() 202 | 203 | r := newTokenizer(testcase.input) 204 | 205 | var err error 206 | var tokens []token 207 | for { 208 | var tkn token 209 | tkn, err = r.next() 210 | if err != nil { 211 | break 212 | } 213 | tokens = append(tokens, tkn) 214 | } 215 | assert.Equal(t, testcase.expectedTokens, tokens) 216 | assert.True(t, errors.Is(err, testcase.expectedErr)) 217 | }) 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /internal/query/tokens.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import "fmt" 4 | 5 | type token interface { 6 | String() string 7 | Pos() Position 8 | _tokenImpl() 9 | } 10 | 11 | type Position struct { 12 | start int64 13 | end int64 14 | } 15 | 16 | func pos(s int64, e int64) Position { 17 | return Position{ 18 | start: s, 19 | end: e, 20 | } 21 | } 22 | 23 | func (p *Position) String() string { 24 | if p.start != p.end { 25 | return fmt.Sprintf("%d-%d", p.start+1, p.end+1) 26 | } 27 | return fmt.Sprintf("%d", p.start+1) 28 | } 29 | 30 | type valueToken interface { 31 | token 32 | _valueTokenImpl() 33 | } 34 | 35 | type tokenBoolean struct { 36 | p Position 37 | v bool 38 | } 39 | type tokenInteger struct { 40 | p Position 41 | v int 42 | } 43 | type tokenString struct { 44 | p Position 45 | v string 46 | } 47 | 48 | func (t *tokenBoolean) Pos() Position { return t.p } 49 | func (t *tokenInteger) Pos() Position { return t.p } 50 | func (t *tokenString) Pos() Position { return t.p } 51 | func (t *tokenBoolean) String() string { return fmt.Sprintf("%t", t.v) } 52 | func (t *tokenInteger) String() string { return fmt.Sprintf("%d", t.v) } 53 | func (t *tokenString) String() string { return t.v } 54 | func (t *tokenBoolean) _tokenImpl() {} 55 | func (t *tokenInteger) _tokenImpl() {} 56 | func (t *tokenString) _tokenImpl() {} 57 | func (t *tokenBoolean) _valueTokenImpl() {} 58 | func (t *tokenInteger) _valueTokenImpl() {} 59 | func (t *tokenString) _valueTokenImpl() {} 60 | 61 | type punctuationToken interface { 62 | token 63 | _punctuationTokenImpl() 64 | } 65 | 66 | type tokenComma struct { 67 | p Position 68 | } 69 | type tokenParenLeft struct { 70 | p Position 71 | } 72 | type tokenParenRight struct { 73 | p Position 74 | } 75 | 76 | func (t *tokenComma) Pos() Position { return t.p } 77 | func (t *tokenParenLeft) Pos() Position { return t.p } 78 | func (t *tokenParenRight) Pos() Position { return t.p } 79 | func (t *tokenComma) String() string { return ", " } 80 | func (t *tokenParenLeft) String() string { return "(" } 81 | func (t *tokenParenRight) String() string { return ")" } 82 | func (t *tokenComma) _tokenImpl() {} 83 | func (t *tokenParenLeft) _tokenImpl() {} 84 | func (t *tokenParenRight) _tokenImpl() {} 85 | func (t *tokenComma) _punctuationTokenImpl() {} 86 | func (t *tokenParenLeft) _punctuationTokenImpl() {} 87 | func (t *tokenParenRight) _punctuationTokenImpl() {} 88 | 89 | type operatorToken interface { 90 | token 91 | _operatorTokenImpl() 92 | } 93 | 94 | type tokenDelta struct { 95 | p Position 96 | } 97 | type tokenIntersect struct { 98 | p Position 99 | } 100 | type tokenSubtract struct { 101 | p Position 102 | } 103 | type tokenUnion struct { 104 | p Position 105 | } 106 | 107 | func (t *tokenDelta) Pos() Position { return t.p } 108 | func (t *tokenIntersect) Pos() Position { return t.p } 109 | func (t *tokenSubtract) Pos() Position { return t.p } 110 | func (t *tokenUnion) Pos() Position { return t.p } 111 | func (t *tokenDelta) String() string { return " delta " } 112 | func (t *tokenIntersect) String() string { return " inter " } 113 | func (t *tokenSubtract) String() string { return " - " } 114 | func (t *tokenUnion) String() string { return " + " } 115 | func (t *tokenDelta) _tokenImpl() {} 116 | func (t *tokenIntersect) _tokenImpl() {} 117 | func (t *tokenSubtract) _tokenImpl() {} 118 | func (t *tokenUnion) _tokenImpl() {} 119 | func (t *tokenDelta) _operatorTokenImpl() {} 120 | func (t *tokenIntersect) _operatorTokenImpl() {} 121 | func (t *tokenSubtract) _operatorTokenImpl() {} 122 | func (t *tokenUnion) _operatorTokenImpl() {} 123 | 124 | var ( 125 | _ valueToken = &tokenBoolean{} 126 | _ valueToken = &tokenInteger{} 127 | _ valueToken = &tokenString{} 128 | 129 | _ punctuationToken = &tokenComma{} 130 | _ punctuationToken = &tokenParenLeft{} 131 | _ punctuationToken = &tokenParenRight{} 132 | 133 | _ operatorToken = &tokenDelta{} 134 | _ operatorToken = &tokenIntersect{} 135 | _ operatorToken = &tokenSubtract{} 136 | _ operatorToken = &tokenUnion{} 137 | ) 138 | -------------------------------------------------------------------------------- /internal/reveal/replacements_test.go: -------------------------------------------------------------------------------- 1 | package reveal 2 | 3 | import ( 4 | "path/filepath" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | 10 | "github.com/Helcaraxan/gomod/internal/depgraph" 11 | "github.com/Helcaraxan/gomod/internal/modules" 12 | "github.com/Helcaraxan/gomod/internal/testutil" 13 | ) 14 | 15 | var ( 16 | replaceA = Replacement{ 17 | Offender: &modules.ModuleInfo{Path: "offender"}, 18 | Original: "originalA", 19 | Override: "overrideA", 20 | Version: "v1.0.0", 21 | } 22 | replaceB = Replacement{ 23 | Offender: moduleA, 24 | Original: "originalB", 25 | Override: "overrideB", 26 | Version: "v1.0.0", 27 | } 28 | replaceC = Replacement{ 29 | Offender: moduleA, 30 | Original: "originalC", 31 | Override: "./overrideC", 32 | } 33 | replaceD = Replacement{ 34 | Offender: moduleA, 35 | Original: "originalD", 36 | Override: "./overrideD", 37 | } 38 | replaceE = Replacement{ 39 | Offender: &modules.ModuleInfo{Path: "offender-bis"}, 40 | Original: "originalA", 41 | Override: "overrideA-bis", 42 | Version: "v2.0.0", 43 | } 44 | replaceF = Replacement{ 45 | Offender: &modules.ModuleInfo{Path: "offender-tertio"}, 46 | Original: "originalB", 47 | Override: "overrideB-bis", 48 | Version: "v2.0.0", 49 | } 50 | 51 | testReplacements = &Replacements{ 52 | main: "test-module", 53 | topLevel: map[string]string{ 54 | "originalA": "overrideA", 55 | "originalB": "overrideB-bis", 56 | }, 57 | replacedModules: []string{ 58 | "originalA", 59 | "originalB", 60 | "originalC", 61 | }, 62 | originToReplace: map[string][]Replacement{ 63 | "originalA": {replaceA, replaceE}, 64 | "originalB": {replaceB, replaceF}, 65 | "originalC": {replaceC}, 66 | }, 67 | } 68 | 69 | moduleA = &modules.ModuleInfo{ 70 | Main: false, 71 | Path: "moduleA", 72 | Version: "v1.0.0", 73 | GoMod: filepath.Join("testdata", "moduleA", "go.mod"), 74 | } 75 | moduleB = &modules.ModuleInfo{ 76 | Main: false, 77 | Path: filepath.Join("testdata", "moduleB"), 78 | Version: "v1.1.0", 79 | } 80 | moduleC = &modules.ModuleInfo{ 81 | Main: false, 82 | Path: "moduleA", 83 | Version: "v0.1.0", 84 | Replace: moduleA, 85 | GoMod: "nowhere", 86 | } 87 | moduleD = &modules.ModuleInfo{ 88 | Main: false, 89 | Path: "moduleD", 90 | Version: "v0.0.1", 91 | GoMod: "", 92 | } 93 | ) 94 | 95 | func createTestGraph(t *testing.T) *depgraph.DepGraph { 96 | testGraph := depgraph.NewGraph(testutil.TestLogger(t).Log(), "", &modules.ModuleInfo{ 97 | Main: true, 98 | Path: "test/module", 99 | GoMod: filepath.Join("testdata", "mainModule", "go.mod"), 100 | }) 101 | for _, module := range []*modules.ModuleInfo{moduleA, moduleB, moduleC, moduleD} { 102 | testGraph.AddModule(module) 103 | } 104 | return testGraph 105 | } 106 | 107 | func Test_ParseReplaces(t *testing.T) { 108 | t.Parallel() 109 | 110 | testcases := map[string]struct { 111 | input string 112 | offender *modules.ModuleInfo 113 | expected []Replacement 114 | }{ 115 | "SingleReplace": { 116 | input: "replace originalA => overrideA v1.0.0", 117 | offender: &modules.ModuleInfo{Path: "offender"}, 118 | expected: []Replacement{replaceA}, 119 | }, 120 | "MultiReplace": { 121 | input: ` 122 | replace ( 123 | originalB => overrideB v1.0.0 124 | originalC => ./overrideC 125 | ) 126 | `, 127 | offender: moduleA, 128 | expected: []Replacement{ 129 | replaceB, 130 | replaceC, 131 | }, 132 | }, 133 | "MixedReplace": { 134 | input: ` 135 | replace ( 136 | originalB => overrideB v1.0.0 137 | originalC => ./overrideC 138 | ) 139 | 140 | replace originalD => ./overrideD 141 | `, 142 | offender: moduleA, 143 | expected: []Replacement{ 144 | replaceD, 145 | replaceB, 146 | replaceC, 147 | }, 148 | }, 149 | "FullGoMod": { 150 | input: `module github.com/foo/bar 151 | 152 | go = 1.12.5 153 | 154 | require ( 155 | github.com/my-dep/A v1.2.0 156 | github.com/my-dep/B v1.9.2-201905291510-0123456789ab // indirect 157 | originalB v0.4.3 158 | originalC v0.2.3 159 | originalD v0.1.0 160 | ) 161 | 162 | // Override this because it's upstream is broken. 163 | replace originalC => ./overrideC // Bar 164 | 165 | // Moar overrides. 166 | replace ( 167 | // Foo. 168 | originalB => overrideB v1.0.0 169 | originalD => ./overrideD 170 | ) 171 | `, 172 | offender: moduleA, 173 | expected: []Replacement{ 174 | replaceC, 175 | replaceB, 176 | replaceD, 177 | }, 178 | }, 179 | } 180 | 181 | for name := range testcases { 182 | testcase := testcases[name] 183 | t.Run(name, func(t *testing.T) { 184 | t.Parallel() 185 | 186 | log := testutil.TestLogger(t) 187 | output := parseGoModForReplacements(log.Log(), testcase.offender, testcase.input) 188 | assert.Equal(t, testcase.expected, output) 189 | }) 190 | } 191 | } 192 | 193 | func Test_FindReplacements(t *testing.T) { 194 | t.Parallel() 195 | 196 | expectedReplacements := &Replacements{ 197 | main: "test/module", 198 | topLevel: map[string]string{"module/foo": "module/foo-bis"}, 199 | replacedModules: []string{ 200 | "originalB", 201 | "originalC", 202 | "originalD", 203 | }, 204 | originToReplace: map[string][]Replacement{ 205 | "originalB": {replaceB}, 206 | "originalC": {replaceC}, 207 | "originalD": {replaceD}, 208 | }, 209 | } 210 | 211 | replacements, err := FindReplacements(testutil.TestLogger(t).Log(), createTestGraph(t)) 212 | assert.NoError(t, err, "Should not error while searching for replacements.") 213 | assert.Equal(t, expectedReplacements, replacements, "Should find the expected replacement information.") 214 | } 215 | 216 | func Test_FilterReplacements(t *testing.T) { 217 | t.Parallel() 218 | 219 | t.Run("OffenderEmpty", func(t *testing.T) { 220 | filtered := testReplacements.FilterOnOffendingModule(nil) 221 | assert.Equal(t, testReplacements, filtered, "Should return an identical array.") 222 | }) 223 | t.Run("Offender", func(t *testing.T) { 224 | filtered := testReplacements.FilterOnOffendingModule([]string{"offender", "pre-offender", "offender-post"}) 225 | assert.Equal(t, &Replacements{ 226 | main: "test-module", 227 | topLevel: map[string]string{ 228 | "originalA": "overrideA", 229 | "originalB": "overrideB-bis", 230 | }, 231 | replacedModules: []string{ 232 | "originalA", 233 | }, 234 | originToReplace: map[string][]Replacement{ 235 | "originalA": {replaceA}, 236 | }, 237 | }, filtered, "Should filter out the expected replacements.") 238 | }) 239 | 240 | t.Run("OriginsEmpty", func(t *testing.T) { 241 | filtered := testReplacements.FilterOnReplacedModule(nil) 242 | assert.Equal(t, testReplacements, filtered, "Should return an identical array.") 243 | }) 244 | t.Run("Origins", func(t *testing.T) { 245 | filtered := testReplacements.FilterOnReplacedModule([]string{"originalA", "originalC", "not-original"}) 246 | assert.Equal(t, &Replacements{ 247 | main: "test-module", 248 | topLevel: map[string]string{ 249 | "originalA": "overrideA", 250 | "originalB": "overrideB-bis", 251 | }, 252 | replacedModules: []string{ 253 | "originalA", 254 | "originalC", 255 | }, 256 | originToReplace: map[string][]Replacement{ 257 | "originalA": {replaceA, replaceE}, 258 | "originalC": {replaceC}, 259 | }, 260 | }, filtered, "Should filter out the expected replacements.") 261 | }) 262 | } 263 | 264 | func Test_PrintReplacements(t *testing.T) { 265 | t.Parallel() 266 | const expectedOutput = `'originalA' is replaced: 267 | ✓ offender -> overrideA @ v1.0.0 268 | offender-bis -> overrideA-bis @ v2.0.0 269 | 270 | 'originalB' is replaced: 271 | moduleA -> overrideB @ v1.0.0 272 | ✓ offender-tertio -> overrideB-bis @ v2.0.0 273 | 274 | 'originalC' is replaced: 275 | moduleA -> ./overrideC 276 | 277 | [✓] Match with a top-level replace in 'test-module' 278 | ` 279 | 280 | writer := &strings.Builder{} 281 | testReplacements.Print(testutil.TestLogger(t).Log(), writer, nil, nil) 282 | assert.Equal(t, expectedOutput, writer.String(), "Should print the expected output.") 283 | } 284 | 285 | func Test_FindGoModFile(t *testing.T) { 286 | t.Parallel() 287 | 288 | testcases := map[string]struct { 289 | module *modules.ModuleInfo 290 | expectedModule *modules.ModuleInfo 291 | expectedPath string 292 | }{ 293 | "NoModule": { 294 | module: nil, 295 | expectedModule: nil, 296 | expectedPath: "", 297 | }, 298 | "Standard": { 299 | module: moduleA, 300 | expectedModule: moduleA, 301 | expectedPath: filepath.Join("testdata", "moduleA", "go.mod"), 302 | }, 303 | "NoGoMod": { 304 | module: moduleB, 305 | expectedModule: moduleB, 306 | expectedPath: filepath.Join("testdata", "moduleB", "go.mod"), 307 | }, 308 | "Replaced": { 309 | module: moduleC, 310 | expectedModule: moduleA, 311 | expectedPath: filepath.Join("testdata", "moduleA", "go.mod"), 312 | }, 313 | "Invalid": { 314 | module: moduleD, 315 | expectedModule: moduleD, 316 | expectedPath: "", 317 | }, 318 | } 319 | 320 | for name := range testcases { 321 | testcase := testcases[name] 322 | t.Run(name, func(t *testing.T) { 323 | t.Parallel() 324 | 325 | module, goModPath := findGoModFile(testutil.TestLogger(t).Log(), testcase.module) 326 | assert.Equal(t, testcase.expectedModule, module, "Should have determined the used module correctly.") 327 | assert.Equal(t, testcase.expectedPath, goModPath, "Should have determined the correct go.mod path.") 328 | }) 329 | } 330 | } 331 | -------------------------------------------------------------------------------- /internal/reveal/testdata/mainModule/go.mod: -------------------------------------------------------------------------------- 1 | module test/module 2 | 3 | go 1.12 4 | 5 | require ( 6 | module/foo v1.0.0 7 | module/bar v1.0.0 8 | ) 9 | 10 | replace module/foo => module/foo-bis v1.0.0 11 | -------------------------------------------------------------------------------- /internal/reveal/testdata/moduleA/go.mod: -------------------------------------------------------------------------------- 1 | module moduleA 2 | 3 | go 1.12 4 | 5 | require ( 6 | originalB v1.0.0 7 | originalC v1.0.0 8 | originalD v1.0.0 9 | ) 10 | 11 | replace originalD => ./overrideD 12 | 13 | replace ( 14 | originalB => overrideB v1.0.0 15 | originalC => ./overrideC 16 | ) 17 | -------------------------------------------------------------------------------- /internal/reveal/testdata/moduleB/go.mod: -------------------------------------------------------------------------------- 1 | module moduleB 2 | 3 | go 1.12 4 | -------------------------------------------------------------------------------- /internal/testutil/log.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | "testing" 8 | 9 | "go.uber.org/zap/zapcore" 10 | 11 | "github.com/Helcaraxan/gomod/internal/logger" 12 | ) 13 | 14 | func TestLogger(t *testing.T) *logger.Builder { 15 | out := &syncBuffer{Builder: strings.Builder{}} 16 | t.Cleanup(func() { 17 | if t.Failed() { 18 | fmt.Fprintf(os.Stderr, out.String()) 19 | } 20 | }) 21 | 22 | fmt.Fprintf(out, "--- Test %s ---", t.Name()) 23 | dl := logger.NewBuilder(out) 24 | dl.SetDomainLevel("all", zapcore.DebugLevel) 25 | return dl 26 | } 27 | 28 | type syncBuffer struct { 29 | strings.Builder 30 | } 31 | 32 | func (s *syncBuffer) Sync() error { return nil } 33 | -------------------------------------------------------------------------------- /internal/testutil/test_module.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/require" 12 | "gopkg.in/yaml.v3" 13 | ) 14 | 15 | const fakeGoDriver = `#!/usr/bin/env bash 16 | set -e -u -o pipefail 17 | 18 | content_dir="%s" 19 | 20 | if [[ -f error.lock ]]; then 21 | echo >2& "deliberate fake go driver error" 22 | exit 1 23 | fi 24 | 25 | case "$1" in 26 | mod) 27 | cat "${content_dir}/graph-output.txt" 28 | ;; 29 | list) 30 | resource_type="pkg" 31 | for arg in ${@:2}; do 32 | if [[ ${arg} == "-m" ]]; then 33 | resource_type="mod" 34 | fi 35 | done 36 | 37 | files_to_print=() 38 | for arg in ${@:2}; do 39 | if [[ ${arg} == "all" ]]; then 40 | files_to_print=($(ls "${content_dir}/list-${resource_type}"-*.txt)) 41 | break 42 | elif [[ ${arg:0:1} != "-" ]]; then 43 | files_to_print+=("${content_dir}/list-${resource_type}-${arg//\//_}.txt") 44 | fi 45 | done 46 | cat "${files_to_print[@]}" 47 | ;; 48 | *) 49 | echo >2& "Unrecognised command '$1' to fake go driver" 50 | exit 1 51 | ;; 52 | esac 53 | ` 54 | 55 | type TestDefinition interface { 56 | GoDriverError() bool 57 | GoGraphOutput() string 58 | GoListPkgOutput() map[string]string 59 | GoListModOutput() map[string]string 60 | } 61 | 62 | func SetupTestModule(t *testing.T, testDefinitionPath string, testDefinition TestDefinition) string { 63 | tempDir := t.TempDir() 64 | 65 | require.NoError(t, ioutil.WriteFile(filepath.Join(tempDir, "go"), []byte(fmt.Sprintf(fakeGoDriver, tempDir)), 0700)) 66 | 67 | currentEnvPath := os.Getenv("PATH") 68 | os.Setenv("PATH", fmt.Sprintf("%s:%s", tempDir, currentEnvPath)) 69 | 70 | t.Cleanup(func() { 71 | require.NoError(t, os.Setenv("PATH", currentEnvPath)) 72 | }) 73 | 74 | raw, testErr := ioutil.ReadFile(testDefinitionPath) 75 | require.NoError(t, testErr) 76 | require.NoError(t, yaml.Unmarshal(raw, testDefinition)) 77 | 78 | if testDefinition.GoDriverError() { 79 | require.NoError(t, ioutil.WriteFile(filepath.Join(tempDir, "error.lock"), []byte(""), 0600)) 80 | return tempDir 81 | } 82 | 83 | require.NoError(t, ioutil.WriteFile(filepath.Join(tempDir, "graph-output.txt"), []byte(testDefinition.GoGraphOutput()), 0600)) 84 | for mod, output := range testDefinition.GoListModOutput() { 85 | filename := fmt.Sprintf("list-mod-%s.txt", strings.ReplaceAll(mod, "/", "_")) 86 | require.NoError(t, ioutil.WriteFile(filepath.Join(tempDir, filename), []byte(output), 0600)) 87 | } 88 | for pkg, output := range testDefinition.GoListPkgOutput() { 89 | filename := fmt.Sprintf("list-pkg-%s.txt", strings.ReplaceAll(pkg, "/", "_")) 90 | require.NoError(t, ioutil.WriteFile(filepath.Join(tempDir, filename), []byte(output), 0600)) 91 | } 92 | return tempDir 93 | } 94 | -------------------------------------------------------------------------------- /internal/util/exec.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | "strings" 11 | 12 | "go.uber.org/zap" 13 | 14 | "github.com/Helcaraxan/gomod/internal/logger" 15 | ) 16 | 17 | func RunCommand(log *logger.Logger, path string, cmd string, args ...string) (stdout []byte, stderr []byte, err error) { 18 | if !filepath.IsAbs(path) { 19 | if path, err = filepath.Abs(path); err != nil { 20 | return nil, nil, err 21 | } 22 | } 23 | 24 | stdoutBuffer := &bytes.Buffer{} 25 | stderrBuffer := &bytes.Buffer{} 26 | 27 | execCmd := exec.Command(cmd, args...) 28 | execCmd.Dir = path 29 | execCmd.Stdout = stdoutBuffer 30 | execCmd.Stderr = stderrBuffer 31 | 32 | if log.Core().Enabled(zap.DebugLevel) { 33 | execCmd.Stdout = io.MultiWriter(execCmd.Stdout, os.Stderr) 34 | execCmd.Stderr = io.MultiWriter(execCmd.Stderr, os.Stderr) 35 | } 36 | 37 | log.Debug("Running command.", zap.Strings("args", append([]string{execCmd.Path}, execCmd.Args...))) 38 | err = execCmd.Run() 39 | log.Debug( 40 | "Finished running.", 41 | zap.Strings("args", append([]string{execCmd.Path}, execCmd.Args...)), 42 | zap.ByteString("stdout", stdoutBuffer.Bytes()), 43 | zap.ByteString("stderr", stderrBuffer.Bytes()), 44 | ) 45 | if err != nil { 46 | log.Error("Command exited with an error.", zap.Strings("args", append([]string{execCmd.Path}, execCmd.Args...)), zap.Error(err)) 47 | return stdoutBuffer.Bytes(), stderrBuffer.Bytes(), fmt.Errorf("failed to run '%s %s: %s", cmd, strings.Join(args, " "), err) 48 | } 49 | return stdoutBuffer.Bytes(), stderrBuffer.Bytes(), nil 50 | } 51 | -------------------------------------------------------------------------------- /internal/util/fileutil.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "go.uber.org/zap" 9 | 10 | "github.com/Helcaraxan/gomod/internal/logger" 11 | ) 12 | 13 | // PrepareOutputPath ensures that the directory containing the specified path exist. It also checks 14 | // that the full path does not refer to an existing file or directory. If such a path already exists 15 | // an error is returned. 16 | func PrepareOutputPath(log *logger.Logger, outputPath string) (*os.File, error) { 17 | l := log.With(zap.String("output-path", outputPath)) 18 | log.Debug("Preparing output path.") 19 | 20 | // Perform target file sanity checks. 21 | var sanityCheckErr error 22 | if _, err := os.Stat(outputPath); err == nil { 23 | l.Error("The specified output path already exists.") 24 | sanityCheckErr = fmt.Errorf("target file %q already exists", outputPath) 25 | } else if !os.IsNotExist(err) { 26 | l.Error("Failed to check if output path already exists.", zap.Error(err)) 27 | sanityCheckErr = fmt.Errorf("could not stat about %q", outputPath) 28 | } 29 | if sanityCheckErr != nil { 30 | return nil, sanityCheckErr 31 | } 32 | 33 | l.Debug("Ensuring output path folder exists.") 34 | if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil { 35 | l.Error("Failed to create output directory.", zap.Error(err)) 36 | return nil, fmt.Errorf("could not create %q", filepath.Dir(outputPath)) 37 | } 38 | out, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY, 0644) 39 | if err != nil { 40 | l.Error("Could not create output file.", zap.Error(err)) 41 | return nil, err 42 | } 43 | return out, nil 44 | } 45 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | 10 | "github.com/spf13/cobra" 11 | "go.uber.org/zap" 12 | "go.uber.org/zap/zapcore" 13 | 14 | "github.com/Helcaraxan/gomod/internal/analysis" 15 | "github.com/Helcaraxan/gomod/internal/depgraph" 16 | "github.com/Helcaraxan/gomod/internal/logger" 17 | "github.com/Helcaraxan/gomod/internal/parsers" 18 | "github.com/Helcaraxan/gomod/internal/printer" 19 | "github.com/Helcaraxan/gomod/internal/query" 20 | "github.com/Helcaraxan/gomod/internal/reveal" 21 | ) 22 | 23 | type commonArgs struct { 24 | log *logger.Builder 25 | } 26 | 27 | func main() { 28 | var verbose []string 29 | 30 | commonArgs := &commonArgs{ 31 | log: logger.NewBuilder(os.Stderr), 32 | } 33 | 34 | rootCmd := &cobra.Command{ 35 | Use: "gomod", 36 | Short: gomodShort, 37 | Long: gomodLong, 38 | PersistentPreRunE: func(_ *cobra.Command, _ []string) error { 39 | for _, domain := range verbose { 40 | commonArgs.log.SetDomainLevel(domain, zapcore.DebugLevel) 41 | } 42 | 43 | log := commonArgs.log.Domain(logger.InitDomain) 44 | if err := checkToolDependencies(log); err != nil { 45 | return err 46 | } else if err = checkGoModulePresence(log); err != nil { 47 | return err 48 | } 49 | return nil 50 | }, 51 | } 52 | 53 | rootCmd.PersistentFlags().StringSliceVarP( 54 | &verbose, 55 | "verbose", 56 | "v", 57 | nil, 58 | "Verbose output. See 'gomod --help' for more information.", 59 | ) 60 | v := rootCmd.Flag("verbose") 61 | v.NoOptDefVal = "all" 62 | 63 | rootCmd.AddCommand( 64 | initAnalyseCmd(commonArgs), 65 | initGraphCmd(commonArgs), 66 | initRevealCmd(commonArgs), 67 | initVersionCmd(commonArgs), 68 | ) 69 | 70 | if err := rootCmd.Execute(); err != nil { 71 | commonArgs.log.Log().Debug("Exited with an error.", zap.Error(err)) 72 | os.Exit(1) 73 | } 74 | } 75 | 76 | type graphArgs struct { 77 | *commonArgs 78 | 79 | annotate bool 80 | outputPath string 81 | packages bool 82 | style *printer.StyleOptions 83 | 84 | query string 85 | } 86 | 87 | func initGraphCmd(cArgs *commonArgs) *cobra.Command { 88 | cmdArgs := &graphArgs{ 89 | commonArgs: cArgs, 90 | } 91 | 92 | var style string 93 | graphCmd := &cobra.Command{ 94 | Use: "graph ", 95 | Short: graphShort, 96 | Long: graphLong, 97 | Args: cobra.MaximumNArgs(1), 98 | RunE: func(cmd *cobra.Command, args []string) error { 99 | if cmd.Flags().Changed("style") { 100 | styleOptions, err := parsers.ParseStyleConfiguration(cmdArgs.log.Domain(logger.InitDomain), style) 101 | if err != nil { 102 | return err 103 | } 104 | cmdArgs.style = styleOptions 105 | } 106 | if len(args) == 0 { 107 | cmdArgs.query = "**:test" 108 | } else { 109 | cmdArgs.query = args[0] 110 | } 111 | return runGraphCmd(cmdArgs) 112 | }, 113 | } 114 | 115 | graphCmd.Flags().BoolVarP(&cmdArgs.annotate, "annotate", "a", false, "Annotate the graph's nodes and edges with version information") 116 | graphCmd.Flags().StringVarP(&cmdArgs.outputPath, "output", "o", "", "If set dump the output to this location") 117 | graphCmd.Flags().BoolVarP(&cmdArgs.packages, "packages", "p", false, "Operate at package-level instead of module-level on the dependency graph.") 118 | graphCmd.Flags().StringVar(&style, "style", "", "Set style options that add decorations and optimisations to the produced 'dot' output.") 119 | 120 | return graphCmd 121 | } 122 | 123 | func runGraphCmd(args *graphArgs) error { 124 | graph, err := depgraph.GetGraph(args.log, "") 125 | if err != nil { 126 | return err 127 | } 128 | 129 | q, err := query.Parse(args.log, args.query) 130 | if err != nil { 131 | return err 132 | } 133 | l := depgraph.LevelModules 134 | if args.packages { 135 | l = depgraph.LevelPackages 136 | } 137 | if err = graph.ApplyQuery(args.log, q, l); err != nil { 138 | return err 139 | } 140 | args.log.Log().Debug("Printing graph.") 141 | return printResult(graph, args) 142 | } 143 | 144 | type analyseArgs struct { 145 | *commonArgs 146 | } 147 | 148 | func initAnalyseCmd(cArgs *commonArgs) *cobra.Command { 149 | cmdArgs := &analyseArgs{ 150 | commonArgs: cArgs, 151 | } 152 | 153 | analyseCmd := &cobra.Command{ 154 | Use: "analyse", 155 | Aliases: []string{"analyze"}, // nolint 156 | Short: analyseShort, 157 | RunE: func(_ *cobra.Command, _ []string) error { 158 | return runAnalyseCmd(cmdArgs) 159 | }, 160 | } 161 | return analyseCmd 162 | } 163 | 164 | func runAnalyseCmd(args *analyseArgs) error { 165 | graph, err := depgraph.GetGraph(args.log, "") 166 | if err != nil { 167 | return err 168 | } 169 | analysisResult, err := analysis.Analyse(args.log.Log(), graph) 170 | if err != nil { 171 | return err 172 | } 173 | return analysisResult.Print(os.Stdout) 174 | } 175 | 176 | type revealArgs struct { 177 | *commonArgs 178 | sources []string 179 | targets []string 180 | } 181 | 182 | func initRevealCmd(cArgs *commonArgs) *cobra.Command { 183 | cmdArgs := &revealArgs{ 184 | commonArgs: cArgs, 185 | } 186 | 187 | revealCmd := &cobra.Command{ 188 | Use: "reveal", 189 | Short: revealShort, 190 | RunE: func(_ *cobra.Command, _ []string) error { 191 | return runRevealCmd(cmdArgs) 192 | }, 193 | } 194 | 195 | revealCmd.Flags().StringSliceVarP(&cmdArgs.sources, "sources", "s", nil, "Filter all places that are replacing dependencies.") 196 | revealCmd.Flags().StringSliceVarP(&cmdArgs.targets, "targets", "t", nil, "Filter all places that replace the specified modules.") 197 | 198 | return revealCmd 199 | } 200 | 201 | func runRevealCmd(args *revealArgs) error { 202 | graph, err := depgraph.GetGraph(args.log, "") 203 | if err != nil { 204 | return err 205 | } 206 | replacements, err := reveal.FindReplacements(args.log.Log(), graph) 207 | if err != nil { 208 | return err 209 | } 210 | return replacements.Print(args.log.Log(), os.Stdout, args.sources, args.targets) 211 | } 212 | 213 | type versionArgs struct { 214 | *commonArgs 215 | } 216 | 217 | func initVersionCmd(cArgs *commonArgs) *cobra.Command { 218 | cmdArgs := &versionArgs{ 219 | commonArgs: cArgs, 220 | } 221 | 222 | versionCmd := &cobra.Command{ 223 | Use: "version", 224 | Short: versionShort, 225 | RunE: func(_ *cobra.Command, _ []string) error { 226 | return runVersionCmd(cmdArgs) 227 | }, 228 | } 229 | 230 | return versionCmd 231 | } 232 | 233 | func runVersionCmd(args *versionArgs) error { 234 | fmt.Printf("%s - built on %s from %s\n", version, date, commit) 235 | return nil 236 | } 237 | 238 | func checkToolDependencies(log *logger.Logger) error { 239 | tools := []string{ 240 | "go", 241 | } 242 | 243 | success := true 244 | for _, tool := range tools { 245 | if _, err := exec.LookPath(tool); err != nil { 246 | success = false 247 | log.Error("A tool dependency does not seem to be available. Please install it first.", zap.String("tool", tool)) 248 | } 249 | } 250 | if !success { 251 | return errors.New("missing tool dependencies") 252 | } 253 | return nil 254 | } 255 | 256 | func checkGoModulePresence(log *logger.Logger) error { 257 | path, err := os.Getwd() 258 | if err != nil { 259 | log.Error("Could not determine the current working directory.", zap.Error(err)) 260 | return err 261 | } 262 | 263 | for { 264 | if _, err = os.Stat(filepath.Join(path, "go.mod")); err == nil { 265 | return nil 266 | } 267 | if path != filepath.VolumeName(path)+string(filepath.Separator) { 268 | break 269 | } 270 | } 271 | log.Error("This tool should be run from within a Go module.") 272 | return errors.New("missing go module") 273 | } 274 | 275 | func printResult(g *depgraph.DepGraph, args *graphArgs) error { 276 | l := printer.LevelModules 277 | if args.packages { 278 | l = printer.LevelPackages 279 | } 280 | return printer.Print(g.Graph, &printer.PrintConfig{ 281 | Log: args.log.Domain(logger.PrinterDomain), 282 | Granularity: l, 283 | OutputPath: args.outputPath, 284 | Style: args.style, 285 | Annotate: args.annotate, 286 | }) 287 | } 288 | -------------------------------------------------------------------------------- /main_strings.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | var ( 4 | version = "devel-release" 5 | commit = "unknown-hash" 6 | date = "unknown-date" 7 | ) 8 | 9 | const ( 10 | gomodShort = "A tool to visualise and analyse a Go project's dependency graph." 11 | gomodLong = `A CLI tool for interacting with your Go project's dependency graph on various 12 | levels. See the online documentation or the '--help' for specific sub-commands to discover all the 13 | supported workflows. 14 | 15 | NB: The '--verbose' flag available on each command takes an optional list of strings that allow you 16 | to only get verbose output for specific pieces of logic. The available domains are: 17 | * init -> Code that runs before any actual dependency processing happens. 18 | * graph -> Interactions on the underlying graph representation used by 'gomod'. 19 | * modinfo -> Retrieval of information about all modules involved in the dependency graph. 20 | * pkginfo -> Retrieval of information about all packages involves in the dependency graph. 21 | * moddeps -> Retrieval of information about module-level dependency graph (e.g versions). 22 | * parser -> Parsing of user-specified dependency queries. 23 | * query -> Execution of a parsed user-query on the dependency graph. 24 | * printer -> Printing of the queried dependency graph. 25 | * all -> Covers all domains above. 26 | Without any arguments the behaviour defaults to enabling verbosity on all domains, the 27 | equivalent of passing 'all' as argument. 28 | ` 29 | 30 | graphShort = "Visualise the dependency graph of a Go module." 31 | graphLong = `Generate a visualisation of the dependency network used by the code in your Go 32 | module. 33 | 34 | The command requires a query to be passed to determine what part of the graph 35 | should be printed. The query language itself supports the following syntax: 36 | 37 | - Exact or prefix path queries: foo.com/bar or foo.com/bar/... 38 | - Inclusion of test-only dependencies: test(foo.com/bar) 39 | - Dependency queries: 'deps(foo.com/bar)' or 'rdeps(foo.com/bar) 40 | - Depth-limited variants of the above: 'deps(foo.com/bar, 5)' 41 | - Recursive removal of single-parent leaf-nodes: shared(foo.com/bar)' 42 | - Various set operations: X + Y, X - Y, X inter Y, X delta Y. 43 | 44 | An example query: 45 | 46 | gomod graph -p 'deps(foo.com/bar/...) inter deps(test(test.io/pkg/tool))' 47 | 48 | The generated graph is colour and format coded: 49 | - Each module, or group of packages belonging to the same module, has a distinct 50 | colour. 51 | - Test-only dependencies are recognisable by the use of a lighter 52 | colour-palette. 53 | - Test-only edges are recognisable by a light blue colour. 54 | - Edges reflecting indirect module dependencies are marked with dashed instead 55 | of continuous lines. 56 | 57 | Other visual aspects (when run through the 'dot' tool) can be tuned with the 58 | '--style' flag. You can specify any formatting options as 59 | 60 | '