blob: e721c26997361c886381a4c51b6c688a9013ecc0 [file] [log] [blame]
// Copyright 2012 Google Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.gitiles;
import static com.google.common.base.MoreObjects.firstNonNull;
import static com.google.common.base.MoreObjects.toStringHelper;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
import static com.google.gitiles.GitilesUrls.escapeName;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Joiner;
import com.google.common.base.MoreObjects.ToStringHelper;
import com.google.common.base.Splitter;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.LinkedListMultimap;
import com.google.common.collect.ListMultimap;
import com.google.common.collect.Multimaps;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.revwalk.RevObject;
/**
* Information about a view in Gitiles.
*
* <p>Views are uniquely identified by a type, and dispatched to servlet types by {@link
* GitilesServlet}. This class contains the list of all types, as well as some methods containing
* basic information parsed from the URL. Construction happens in {@link ViewFilter}.
*/
public class GitilesView {
private static final String DEFAULT_ARCHIVE_EXTENSION = ".tar.gz";
/** All the possible view types supported in the application. */
public enum Type {
HOST_INDEX,
REPOSITORY_INDEX,
REFS,
REVISION,
PATH,
SHOW,
DIFF,
LOG,
DESCRIBE,
ARCHIVE,
BLAME,
DOC,
ROOTED_DOC;
}
/** Exception thrown when building a view that is invalid. */
public static class InvalidViewException extends IllegalStateException {
private static final long serialVersionUID = 1L;
public InvalidViewException(String msg) {
super(msg);
}
}
/** Builder for views. */
public static class Builder {
private Type type;
private final ListMultimap<String, String> params = LinkedListMultimap.create();
private String hostName;
private String servletPath;
private String repositoryPrefix;
private String repositoryName;
private Revision revision = Revision.NULL;
private Revision oldRevision = Revision.NULL;
private String path;
private String extension;
private String anchor;
private Builder(Type type) {
this.type = type;
}
public Builder copyFrom(GitilesView other) {
if (type == Type.DOC && other.type == Type.ROOTED_DOC) {
type = Type.ROOTED_DOC;
}
hostName = other.hostName;
servletPath = other.servletPath;
switch (type) {
case HOST_INDEX:
repositoryPrefix = other.repositoryPrefix;
break;
case LOG:
case DIFF:
oldRevision = other.oldRevision;
// $FALL-THROUGH$
case PATH:
case DOC:
case ROOTED_DOC:
case ARCHIVE:
case BLAME:
case SHOW:
path = other.path;
// $FALL-THROUGH$
case REVISION:
revision = other.revision;
// $FALL-THROUGH$
case DESCRIBE:
case REFS:
case REPOSITORY_INDEX:
repositoryName = other.repositoryName;
// $FALL-THROUGH$
default:
break;
}
if (other.type == type) {
// Only copy params for matching type.
params.putAll(other.params);
if (type == Type.ARCHIVE) {
extension = other.extension;
}
}
return this;
}
public Builder copyFrom(HttpServletRequest req) {
return copyFrom(ViewFilter.getView(req));
}
public Builder setHostName(String hostName) {
this.hostName = checkNotNull(hostName);
return this;
}
public String getHostName() {
return hostName;
}
public Builder setServletPath(String servletPath) {
this.servletPath = checkNotNull(servletPath);
return this;
}
public String getServletPath() {
return servletPath;
}
public Builder setRepositoryPrefix(String prefix) {
if (type == Type.HOST_INDEX) {
this.repositoryPrefix =
prefix != null ? Strings.emptyToNull(maybeTrimLeadingAndTrailingSlash(prefix)) : null;
return this;
}
throw new IllegalStateException(
String.format("cannot set repository prefix on %s view", type));
}
public Builder setRepositoryName(String repositoryName) {
if (type == Type.HOST_INDEX) {
throw new IllegalStateException(
String.format("cannot set repository name on %s view", type));
}
this.repositoryName = checkNotNull(repositoryName);
return this;
}
public String getRepositoryName() {
return repositoryName;
}
public Builder setRevision(Revision revision) {
switch (type) {
case HOST_INDEX:
case REPOSITORY_INDEX:
case REFS:
case DESCRIBE:
throw new IllegalStateException(String.format("cannot set revision on %s view", type));
case ARCHIVE:
case BLAME:
case DIFF:
case DOC:
case LOG:
case PATH:
case REVISION:
case ROOTED_DOC:
case SHOW:
default:
this.revision = checkNotNull(revision);
return this;
}
}
public Builder setRevision(String name) {
return setRevision(Revision.named(name));
}
public Builder setRevision(RevObject obj) {
return setRevision(Revision.peeled(obj.name(), obj));
}
public Builder setRevision(String name, RevObject obj) {
return setRevision(Revision.peeled(name, obj));
}
public Revision getRevision() {
return revision;
}
public Builder setOldRevision(Revision revision) {
if (type != Type.DIFF && type != Type.LOG) {
revision = firstNonNull(revision, Revision.NULL);
checkState(Revision.isNull(revision), "cannot set old revision on %s view", type);
}
this.oldRevision = revision;
return this;
}
public Builder setOldRevision(RevObject obj) {
return setOldRevision(Revision.peeled(obj.name(), obj));
}
public Builder setOldRevision(String name, RevObject obj) {
return setOldRevision(Revision.peeled(name, obj));
}
public Revision getOldRevision() {
return oldRevision;
}
public Builder setPathPart(String path) {
switch (type) {
case PATH:
case DIFF:
case SHOW:
checkState(path != null, "cannot set null path on %s view", type);
break;
case BLAME:
case ARCHIVE:
case DESCRIBE:
case REFS:
case LOG:
case DOC:
case ROOTED_DOC:
break;
case HOST_INDEX:
case REPOSITORY_INDEX:
case REVISION:
default:
checkState(path == null, "cannot set path on %s view", type);
break;
}
this.path = path != null ? maybeTrimLeadingAndTrailingSlash(path) : null;
return this;
}
public String getPathPart() {
return path;
}
public Builder setExtension(String extension) {
if (type != Type.ARCHIVE) {
checkState(extension == null, "cannot set extension on %s view", type);
}
this.extension = extension;
return this;
}
public String getExtension() {
return extension;
}
public Builder putParam(String key, String value) {
params.put(key, value);
return this;
}
public Builder replaceParam(String key, String value) {
params.replaceValues(key, ImmutableList.of(value));
return this;
}
public Builder putAllParams(Map<String, String[]> params) {
for (Map.Entry<String, String[]> e : params.entrySet()) {
this.params.putAll(e.getKey(), Arrays.asList(e.getValue()));
}
return this;
}
public ListMultimap<String, String> getParams() {
return params;
}
public Builder setAnchor(String anchor) {
this.anchor = anchor;
return this;
}
public String getAnchor() {
return anchor;
}
public GitilesView build() {
switch (type) {
case HOST_INDEX:
checkHostIndex();
break;
case REPOSITORY_INDEX:
checkRepositoryIndex();
break;
case REFS:
checkRefs();
break;
case DESCRIBE:
checkDescribe();
break;
case REVISION:
checkRevision();
break;
case PATH:
case SHOW:
case DOC:
checkPath();
break;
case DIFF:
checkDiff();
break;
case LOG:
checkLog();
break;
case ARCHIVE:
checkArchive();
break;
case BLAME:
checkBlame();
break;
case ROOTED_DOC:
checkRootedDoc();
break;
}
return new GitilesView(
type,
hostName,
servletPath,
repositoryPrefix,
repositoryName,
revision,
oldRevision,
path,
extension,
params,
anchor);
}
public String toUrl() {
return build().toUrl();
}
private void checkView(boolean expr, String msg, Object... args) {
if (!expr) {
throw new InvalidViewException(String.format(msg, args));
}
}
private void checkHostIndex() {
checkView(hostName != null, "missing hostName on %s view", type);
checkView(servletPath != null, "missing servletPath on %s view", type);
}
private void checkRepositoryIndex() {
checkView(repositoryName != null, "missing repository name on %s view", type);
checkHostIndex();
}
private void checkRefs() {
checkRepositoryIndex();
}
private void checkDescribe() {
checkRepositoryIndex();
}
private void checkRevision() {
checkView(!Revision.isNull(revision), "missing revision on %s view", type);
checkRepositoryIndex();
}
private void checkDiff() {
checkPath();
}
private void checkLog() {
checkRepositoryIndex();
}
private void checkPath() {
checkView(path != null, "missing path on %s view", type);
checkRevision();
}
private void checkArchive() {
checkRevision();
}
private void checkBlame() {
checkPath();
}
private void checkRootedDoc() {
checkView(hostName != null, "missing hostName on %s view", type);
checkView(servletPath != null, "missing hostName on %s view", type);
checkView(!Revision.isNull(revision), "missing revision on %s view", type);
checkView(path != null, "missing path on %s view", type);
}
}
public static Builder hostIndex() {
return new Builder(Type.HOST_INDEX);
}
public static Builder repositoryIndex() {
return new Builder(Type.REPOSITORY_INDEX);
}
public static Builder refs() {
return new Builder(Type.REFS);
}
public static Builder describe() {
return new Builder(Type.DESCRIBE);
}
public static Builder revision() {
return new Builder(Type.REVISION);
}
public static Builder path() {
return new Builder(Type.PATH);
}
public static Builder show() {
return new Builder(Type.SHOW);
}
public static Builder diff() {
return new Builder(Type.DIFF);
}
public static Builder log() {
return new Builder(Type.LOG);
}
public static Builder archive() {
return new Builder(Type.ARCHIVE);
}
public static Builder blame() {
return new Builder(Type.BLAME);
}
public static Builder doc() {
return new Builder(Type.DOC);
}
public static Builder rootedDoc() {
return new Builder(Type.ROOTED_DOC);
}
static String maybeTrimLeadingAndTrailingSlash(String str) {
if (str.startsWith("/")) {
str = str.substring(1);
}
return !str.isEmpty() && str.endsWith("/") ? str.substring(0, str.length() - 1) : str;
}
private final Type type;
private final String hostName;
private final String servletPath;
private final String repositoryPrefix;
private final String repositoryName;
private final Revision revision;
private final Revision oldRevision;
private final String path;
private final String extension;
private final ListMultimap<String, String> params;
private final String anchor;
private GitilesView(
Type type,
String hostName,
String servletPath,
String repositoryPrefix,
String repositoryName,
Revision revision,
Revision oldRevision,
String path,
String extension,
ListMultimap<String, String> params,
String anchor) {
this.type = type;
this.hostName = hostName;
this.servletPath = servletPath;
this.repositoryPrefix = repositoryPrefix;
this.repositoryName = repositoryName;
this.revision = firstNonNull(revision, Revision.NULL);
this.oldRevision = firstNonNull(oldRevision, Revision.NULL);
this.path = path;
this.extension = extension;
this.params = Multimaps.unmodifiableListMultimap(params);
this.anchor = anchor;
}
public Builder copyFrom(GitilesView other) {
return new Builder(other.type).copyFrom(this);
}
public Builder toBuilder() {
return copyFrom(this);
}
public String getHostName() {
return hostName;
}
public String getServletPath() {
return servletPath;
}
public String getRepositoryPrefix() {
return repositoryPrefix;
}
public String getRepositoryName() {
return repositoryName;
}
public Revision getRevision() {
return revision;
}
public Revision getOldRevision() {
return oldRevision;
}
public String getRevisionRange() {
if (Revision.isNull(oldRevision)) {
if (type == Type.LOG || type == Type.DIFF) {
// For types that require two revisions, NULL indicates the empty
// tree/commit.
return revision.getName() + "^!";
}
// For everything else NULL indicates it is not a range, just a single
// revision.
return null;
} else if (type == Type.DIFF && isFirstParent(revision, oldRevision)) {
return revision.getName() + "^!";
} else {
return oldRevision.getName() + ".." + revision.getName();
}
}
public String getPathPart() {
return path;
}
public String getExtension() {
return extension;
}
public ListMultimap<String, String> getParameters() {
return params;
}
public String getAnchor() {
return anchor;
}
public Type getType() {
return type;
}
@Override
public String toString() {
ToStringHelper b =
toStringHelper(type.toString())
.omitNullValues()
.add("host", hostName)
.add("servlet", servletPath)
.add("prefix", repositoryPrefix)
.add("repo", repositoryName)
.add("rev", revision)
.add("old", oldRevision)
.add("path", path)
.add("extension", extension);
if (!params.isEmpty()) {
b.add("params", params);
}
b.add("anchor", anchor);
return b.toString();
}
/** @return an escaped, relative URL representing this view. */
public String toUrl() {
StringBuilder url = new StringBuilder(servletPath).append('/');
ListMultimap<String, String> params = this.params;
switch (type) {
case HOST_INDEX:
if (repositoryPrefix != null) {
url.append(repositoryPrefix).append('/');
}
params = LinkedListMultimap.create();
if (repositoryPrefix == null && !this.params.containsKey("format")) {
params.put("format", FormatType.HTML.toString());
}
params.putAll(this.params);
break;
case REPOSITORY_INDEX:
url.append(repositoryName).append('/');
break;
case REFS:
url.append(repositoryName).append("/+refs");
break;
case DESCRIBE:
url.append(repositoryName).append("/+describe");
break;
case REVISION:
url.append(repositoryName).append("/+/").append(revision.getName());
break;
case ARCHIVE:
url.append(repositoryName).append("/+archive/").append(revision.getName());
if (path != null) {
url.append('/').append(path);
}
url.append(firstNonNull(extension, DEFAULT_ARCHIVE_EXTENSION));
break;
case PATH:
url.append(repositoryName)
.append("/+/")
.append(revision.getName())
.append('/')
.append(path);
break;
case SHOW:
url.append(repositoryName)
.append("/+show/")
.append(revision.getName())
.append('/')
.append(path);
break;
case DIFF:
url.append(repositoryName).append("/+/");
if (isFirstParent(revision, oldRevision)) {
url.append(revision.getName()).append("^!");
} else {
url.append(oldRevision.getName()).append("..").append(revision.getName());
}
url.append('/').append(path);
break;
case LOG:
url.append(repositoryName).append("/+log");
if (!Revision.isNull(revision)) {
url.append('/');
if (!Revision.isNull(oldRevision)) {
url.append(oldRevision.getName()).append("..");
}
url.append(revision.getName());
if (path != null) {
url.append('/').append(path);
}
}
break;
case BLAME:
url.append(repositoryName)
.append("/+blame/")
.append(revision.getName())
.append('/')
.append(path);
break;
case DOC:
url.append(repositoryName);
if (path != null && path.endsWith(".md")) {
url.append("/+/");
} else {
url.append("/+doc/");
}
url.append(revision.getName());
if (path != null) {
url.append('/').append(path);
}
break;
case ROOTED_DOC:
if (path != null) {
url.append(path);
}
break;
default:
throw new IllegalStateException("Unknown view type: " + type);
}
String baseUrl = escapeName(url.toString());
url = new StringBuilder();
if (!params.isEmpty()) {
url.append('?').append(paramsToString(params));
}
if (!Strings.isNullOrEmpty(anchor)) {
url.append('#').append(escapeName(anchor));
}
return baseUrl + url;
}
/**
* @return a list of maps with "text" and "url" keys for all file paths leading up to the path
* represented by this view. All URLs allow auto-diving into one-entry subtrees; see also
* {@link #getBreadcrumbs(List)}.
*/
public List<Map<String, String>> getBreadcrumbs() {
return getBreadcrumbs(null);
}
private static final EnumSet<Type> NON_HTML_TYPES = EnumSet.of(Type.DESCRIBE, Type.ARCHIVE);
/**
* @param hasSingleTree list of booleans, one per path entry in this view's path excluding the
* leaf. True entries indicate the tree at that path only has a single entry that is another
* tree.
* @return a list of maps with "text" and "url" keys for all file paths leading up to the path
* represented by this view. URLs whose corresponding entry in {@code hasSingleTree} is true
* will disable auto-diving into one-entry subtrees.
*/
public List<Map<String, String>> getBreadcrumbs(List<Boolean> hasSingleTree) {
checkArgument(!NON_HTML_TYPES.contains(type), "breadcrumbs for %s view not supported", type);
checkArgument(
type != Type.REFS || Strings.isNullOrEmpty(path),
"breadcrumbs for REFS view with path not supported");
checkArgument(
hasSingleTree == null || type == Type.PATH, "hasSingleTree must be null for %s view", type);
String path = this.path;
ImmutableList.Builder<Map<String, String>> breadcrumbs = ImmutableList.builder();
breadcrumbs.add(breadcrumb(hostName, hostIndex().copyFrom(this).setRepositoryPrefix(null)));
if (repositoryPrefix != null) {
breadcrumbs.addAll(hostIndexBreadcrumbs(repositoryPrefix));
} else if (repositoryName != null) {
breadcrumbs.addAll(hostIndexBreadcrumbs(repositoryName));
}
if (type == Type.DIFF) {
// TODO(dborowitz): Tweak the breadcrumbs template to allow us to render
// separate links in "old..new".
breadcrumbs.add(breadcrumb(getRevisionRange(), diff().copyFrom(this).setPathPart("")));
} else if (type == Type.LOG) {
if (!Revision.isNull(revision)) {
// TODO(dborowitz): Add something in the navigation area (probably not
// a breadcrumb) to allow switching between /+log/ and /+/.
if (Revision.isNull(oldRevision)) {
breadcrumbs.add(breadcrumb(revision.getName(), log().copyFrom(this).setPathPart(null)));
} else {
breadcrumbs.add(breadcrumb(getRevisionRange(), log().copyFrom(this).setPathPart(null)));
}
} else {
breadcrumbs.add(breadcrumb(Constants.HEAD, log().copyFrom(this)));
}
path = Strings.emptyToNull(path);
} else if (!Revision.isNull(revision)) {
breadcrumbs.add(breadcrumb(revision.getName(), revision().copyFrom(this)));
}
if (path != null) {
if (type != Type.LOG && type != Type.REFS) {
// The "." breadcrumb would be no different for LOG or REFS.
breadcrumbs.add(breadcrumb(".", copyWithPath(false).setPathPart("")));
}
StringBuilder cur = new StringBuilder();
List<String> parts = PathUtil.SPLITTER.omitEmptyStrings().splitToList(path);
checkArgument(
hasSingleTree == null
|| (parts.isEmpty() && hasSingleTree.isEmpty())
|| hasSingleTree.size() == parts.size() - 1,
"hasSingleTree has wrong number of entries");
for (int i = 0; i < parts.size(); i++) {
String part = parts.get(i);
cur.append(part).append('/');
String curPath = cur.toString();
boolean isLeaf = i == parts.size() - 1;
Builder builder = copyWithPath(isLeaf).setPathPart(curPath);
if (hasSingleTree != null && i < parts.size() - 1 && hasSingleTree.get(i)) {
builder.replaceParam(PathServlet.AUTODIVE_PARAM, PathServlet.NO_AUTODIVE_VALUE);
}
breadcrumbs.add(breadcrumb(part, builder));
}
}
return breadcrumbs.build();
}
private List<Map<String, String>> hostIndexBreadcrumbs(String name) {
List<String> parts = Splitter.on('/').splitToList(name);
List<Map<String, String>> r = new ArrayList<>(parts.size());
for (int i = 0; i < parts.size(); i++) {
String prefix = Joiner.on('/').join(parts.subList(0, i + 1));
r.add(breadcrumb(parts.get(i), hostIndex().copyFrom(this).setRepositoryPrefix(prefix)));
}
return r;
}
private static Map<String, String> breadcrumb(String text, Builder url) {
return ImmutableMap.of("text", text, "url", url.toUrl());
}
private Builder copyWithPath(boolean isLeaf) {
Builder copy;
switch (type) {
case DIFF:
copy = diff();
break;
case LOG:
copy = log();
break;
case BLAME:
copy = isLeaf ? blame() : path();
break;
case ARCHIVE:
case DESCRIBE:
case DOC:
case HOST_INDEX:
case PATH:
case REFS:
case REPOSITORY_INDEX:
case REVISION:
case ROOTED_DOC:
case SHOW:
default:
copy = path();
break;
}
return copy.copyFrom(this);
}
private static boolean isFirstParent(Revision rev1, Revision rev2) {
return Revision.isNull(rev2)
|| rev2.getName().equals(rev1.getName() + "^")
|| rev2.getName().equals(rev1.getName() + "~1");
}
@VisibleForTesting
static String paramsToString(ListMultimap<String, String> params) {
try {
StringBuilder sb = new StringBuilder();
boolean first = true;
for (Map.Entry<String, String> e : params.entries()) {
if (!first) {
sb.append('&');
} else {
first = false;
}
sb.append(URLEncoder.encode(e.getKey(), UTF_8.name()));
if (!"".equals(e.getValue())) {
sb.append('=').append(URLEncoder.encode(e.getValue(), UTF_8.name()));
}
}
return sb.toString();
} catch (UnsupportedEncodingException e) {
throw new IllegalStateException(e);
}
}
}