1 /*
2  * Copyright (C) 2010 The Android Open Source Project
3  *
4  * Licensed under the Eclipse Public License, Version 1.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.eclipse.org/org/documents/epl-v10.php
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.ide.eclipse.adt.internal.editors.layout.gle2;
18 
19 import static com.android.SdkConstants.ATTR_LAYOUT;
20 import static com.android.SdkConstants.EXT_XML;
21 import static com.android.SdkConstants.FD_RESOURCES;
22 import static com.android.SdkConstants.FD_RES_LAYOUT;
23 import static com.android.SdkConstants.TOOLS_URI;
24 import static com.android.SdkConstants.VIEW_FRAGMENT;
25 import static com.android.SdkConstants.VIEW_INCLUDE;
26 import static com.android.ide.eclipse.adt.AdtConstants.WS_LAYOUTS;
27 import static com.android.ide.eclipse.adt.AdtConstants.WS_SEP;
28 import static com.android.resources.ResourceType.LAYOUT;
29 import static org.eclipse.core.resources.IResourceDelta.ADDED;
30 import static org.eclipse.core.resources.IResourceDelta.CHANGED;
31 import static org.eclipse.core.resources.IResourceDelta.CONTENT;
32 import static org.eclipse.core.resources.IResourceDelta.REMOVED;
33 
34 import com.android.annotations.NonNull;
35 import com.android.annotations.Nullable;
36 import com.android.annotations.VisibleForTesting;
37 import com.android.ide.common.resources.ResourceFile;
38 import com.android.ide.common.resources.ResourceFolder;
39 import com.android.ide.common.resources.ResourceItem;
40 import com.android.ide.eclipse.adt.AdtPlugin;
41 import com.android.ide.eclipse.adt.internal.project.BaseProjectHelper;
42 import com.android.ide.eclipse.adt.internal.resources.manager.ProjectResources;
43 import com.android.ide.eclipse.adt.internal.resources.manager.ResourceManager;
44 import com.android.ide.eclipse.adt.internal.resources.manager.ResourceManager.IResourceListener;
45 import com.android.ide.eclipse.adt.io.IFileWrapper;
46 import com.android.io.IAbstractFile;
47 import com.android.resources.ResourceType;
48 
49 import org.eclipse.core.resources.IFile;
50 import org.eclipse.core.resources.IMarker;
51 import org.eclipse.core.resources.IProject;
52 import org.eclipse.core.resources.IResource;
53 import org.eclipse.core.runtime.CoreException;
54 import org.eclipse.core.runtime.IStatus;
55 import org.eclipse.core.runtime.QualifiedName;
56 import org.eclipse.swt.widgets.Display;
57 import org.eclipse.wst.sse.core.StructuredModelManager;
58 import org.eclipse.wst.sse.core.internal.provisional.IModelManager;
59 import org.eclipse.wst.sse.core.internal.provisional.IStructuredModel;
60 import org.eclipse.wst.xml.core.internal.provisional.document.IDOMModel;
61 import org.w3c.dom.Document;
62 import org.w3c.dom.Element;
63 import org.w3c.dom.Node;
64 import org.w3c.dom.NodeList;
65 
66 import java.util.ArrayList;
67 import java.util.Collection;
68 import java.util.Collections;
69 import java.util.HashMap;
70 import java.util.HashSet;
71 import java.util.LinkedList;
72 import java.util.List;
73 import java.util.Map;
74 import java.util.Set;
75 
76 /**
77  * The include finder finds other XML files that are including a given XML file, and does
78  * so efficiently (caching results across IDE sessions etc).
79  */
80 @SuppressWarnings("restriction") // XML model
81 public class IncludeFinder {
82     /** Qualified name for the per-project persistent property include-map */
83     private final static QualifiedName CONFIG_INCLUDES = new QualifiedName(AdtPlugin.PLUGIN_ID,
84             "includes");//$NON-NLS-1$
85 
86     /**
87      * Qualified name for the per-project non-persistent property storing the
88      * {@link IncludeFinder} for this project
89      */
90     private final static QualifiedName INCLUDE_FINDER = new QualifiedName(AdtPlugin.PLUGIN_ID,
91             "includefinder"); //$NON-NLS-1$
92 
93     /** Project that the include finder locates includes for */
94     private final IProject mProject;
95 
96     /** Map from a layout resource name to a set of layouts included by the given resource */
97     private Map<String, List<String>> mIncludes = null;
98 
99     /**
100      * Reverse map of {@link #mIncludes}; points to other layouts that are including a
101      * given layouts
102      */
103     private Map<String, List<String>> mIncludedBy = null;
104 
105     /** Flag set during a refresh; ignore updates when this is true */
106     private static boolean sRefreshing;
107 
108     /** Global (cross-project) resource listener */
109     private static ResourceListener sListener;
110 
111     /**
112      * Constructs an {@link IncludeFinder} for the given project. Don't use this method;
113      * use the {@link #get} factory method instead.
114      *
115      * @param project project to create an {@link IncludeFinder} for
116      */
IncludeFinder(IProject project)117     private IncludeFinder(IProject project) {
118         mProject = project;
119     }
120 
121     /**
122      * Returns the {@link IncludeFinder} for the given project
123      *
124      * @param project the project the finder is associated with
125      * @return an {@link IncludeFinder} for the given project, never null
126      */
127     @NonNull
get(IProject project)128     public static IncludeFinder get(IProject project) {
129         IncludeFinder finder = null;
130         try {
131             finder = (IncludeFinder) project.getSessionProperty(INCLUDE_FINDER);
132         } catch (CoreException e) {
133             // Not a problem; we will just create a new one
134         }
135 
136         if (finder == null) {
137             finder = new IncludeFinder(project);
138             try {
139                 project.setSessionProperty(INCLUDE_FINDER, finder);
140             } catch (CoreException e) {
141                 AdtPlugin.log(e, "Can't store IncludeFinder");
142             }
143         }
144 
145         return finder;
146     }
147 
148     /**
149      * Returns a list of resource names that are included by the given resource
150      *
151      * @param includer the resource name to return included layouts for
152      * @return the layouts included by the given resource
153      */
getIncludesFrom(String includer)154     private List<String> getIncludesFrom(String includer) {
155         ensureInitialized();
156 
157         return mIncludes.get(includer);
158     }
159 
160     /**
161      * Gets the list of all other layouts that are including the given layout.
162      *
163      * @param included the file that is included
164      * @return the files that are including the given file, or null or empty
165      */
166     @Nullable
getIncludedBy(IResource included)167     public List<Reference> getIncludedBy(IResource included) {
168         ensureInitialized();
169         String mapKey = getMapKey(included);
170         List<String> result = mIncludedBy.get(mapKey);
171         if (result == null) {
172             String name = getResourceName(included);
173             if (!name.equals(mapKey)) {
174                 result = mIncludedBy.get(name);
175             }
176         }
177 
178         if (result != null && result.size() > 0) {
179             List<Reference> references = new ArrayList<Reference>(result.size());
180             for (String s : result) {
181                 references.add(new Reference(mProject, s));
182             }
183             return references;
184         } else {
185             return null;
186         }
187     }
188 
189     /**
190      * Returns true if the given resource is included from some other layout in the
191      * project
192      *
193      * @param included the resource to check
194      * @return true if the file is included by some other layout
195      */
isIncluded(IResource included)196     public boolean isIncluded(IResource included) {
197         ensureInitialized();
198         String mapKey = getMapKey(included);
199         List<String> result = mIncludedBy.get(mapKey);
200         if (result == null) {
201             String name = getResourceName(included);
202             if (!name.equals(mapKey)) {
203                 result = mIncludedBy.get(name);
204             }
205         }
206 
207         return result != null && result.size() > 0;
208     }
209 
210     @VisibleForTesting
getIncludedBy(String included)211     /* package */ List<String> getIncludedBy(String included) {
212         ensureInitialized();
213         return mIncludedBy.get(included);
214     }
215 
216     /** Initialize the inclusion data structures, if not already done */
ensureInitialized()217     private void ensureInitialized() {
218         if (mIncludes == null) {
219             // Initialize
220             if (!readSettings()) {
221                 // Couldn't read settings: probably the first time this code is running
222                 // so there is no known data about includes.
223 
224                 // Yes, these should be multimaps! If we start using Guava replace
225                 // these with multimaps.
226                 mIncludes = new HashMap<String, List<String>>();
227                 mIncludedBy = new HashMap<String, List<String>>();
228 
229                 scanProject();
230                 saveSettings();
231             }
232         }
233     }
234 
235     // ----- Persistence -----
236 
237     /**
238      * Create a String serialization of the includes map. The map attempts to be compact;
239      * it strips out the @layout/ prefix, and eliminates the values for empty string
240      * values. The map can be restored by calling {@link #decodeMap}. The encoded String
241      * will have sorted keys.
242      *
243      * @param map the map to be serialized
244      * @return a serialization (never null) of the given map
245      */
246     @VisibleForTesting
encodeMap(Map<String, List<String>> map)247     public static String encodeMap(Map<String, List<String>> map) {
248         StringBuilder sb = new StringBuilder();
249 
250         if (map != null) {
251             // Process the keys in sorted order rather than just
252             // iterating over the entry set to ensure stable output
253             List<String> keys = new ArrayList<String>(map.keySet());
254             Collections.sort(keys);
255             for (String key : keys) {
256                 List<String> values = map.get(key);
257 
258                 if (sb.length() > 0) {
259                     sb.append(',');
260                 }
261                 sb.append(key);
262                 if (values.size() > 0) {
263                     sb.append('=').append('>');
264                     sb.append('{');
265                     boolean first = true;
266                     for (String value : values) {
267                         if (first) {
268                             first = false;
269                         } else {
270                             sb.append(',');
271                         }
272                         sb.append(value);
273                     }
274                     sb.append('}');
275                 }
276             }
277         }
278 
279         return sb.toString();
280     }
281 
282     /**
283      * Decodes the encoding (produced by {@link #encodeMap}) back into the original map,
284      * modulo any key sorting differences.
285      *
286      * @param encoded an encoding of a map created by {@link #encodeMap}
287      * @return a map corresponding to the encoded values, never null
288      */
289     @VisibleForTesting
decodeMap(String encoded)290     public static Map<String, List<String>> decodeMap(String encoded) {
291         HashMap<String, List<String>> map = new HashMap<String, List<String>>();
292 
293         if (encoded.length() > 0) {
294             int i = 0;
295             int end = encoded.length();
296 
297             while (i < end) {
298 
299                 // Find key range
300                 int keyBegin = i;
301                 int keyEnd = i;
302                 while (i < end) {
303                     char c = encoded.charAt(i);
304                     if (c == ',') {
305                         break;
306                     } else if (c == '=') {
307                         i += 2; // Skip =>
308                         break;
309                     }
310                     i++;
311                     keyEnd = i;
312                 }
313 
314                 List<String> values = new ArrayList<String>();
315                 // Find values
316                 if (i < end && encoded.charAt(i) == '{') {
317                     i++;
318                     while (i < end) {
319                         int valueBegin = i;
320                         int valueEnd = i;
321                         char c = 0;
322                         while (i < end) {
323                             c = encoded.charAt(i);
324                             if (c == ',' || c == '}') {
325                                 valueEnd = i;
326                                 break;
327                             }
328                             i++;
329                         }
330                         if (valueEnd > valueBegin) {
331                             values.add(encoded.substring(valueBegin, valueEnd));
332                         }
333 
334                         if (c == '}') {
335                             if (i < end-1 && encoded.charAt(i+1) == ',') {
336                                 i++;
337                             }
338                             break;
339                         }
340                         assert c == ',';
341                         i++;
342                     }
343                 }
344 
345                 String key = encoded.substring(keyBegin, keyEnd);
346                 map.put(key, values);
347                 i++;
348             }
349         }
350 
351         return map;
352     }
353 
354     /**
355      * Stores the settings in the persistent project storage.
356      */
saveSettings()357     private void saveSettings() {
358         // Serialize the mIncludes map into a compact String. The mIncludedBy map can be
359         // inferred from it.
360         String encoded = encodeMap(mIncludes);
361 
362         try {
363             if (encoded.length() >= 2048) {
364                 // The maximum length of a setting key is 2KB, according to the javadoc
365                 // for the project class. It's unlikely that we'll
366                 // hit this -- even with an average layout root name of 20 characters
367                 // we can still store over a hundred names. But JUST IN CASE we run
368                 // into this, we'll clear out the key in this name which means that the
369                 // information will need to be recomputed in the next IDE session.
370                 mProject.setPersistentProperty(CONFIG_INCLUDES, null);
371             } else {
372                 String existing = mProject.getPersistentProperty(CONFIG_INCLUDES);
373                 if (!encoded.equals(existing)) {
374                     mProject.setPersistentProperty(CONFIG_INCLUDES, encoded);
375                 }
376             }
377         } catch (CoreException e) {
378             AdtPlugin.log(e, "Can't store include settings");
379         }
380     }
381 
382     /**
383      * Reads previously stored settings from the persistent project storage
384      *
385      * @return true iff settings were restored from the project
386      */
readSettings()387     private boolean readSettings() {
388         try {
389             String encoded = mProject.getPersistentProperty(CONFIG_INCLUDES);
390             if (encoded != null) {
391                 mIncludes = decodeMap(encoded);
392 
393                 // Set up a reverse map, pointing from included files to the files that
394                 // included them
395                 mIncludedBy = new HashMap<String, List<String>>(2 * mIncludes.size());
396                 for (Map.Entry<String, List<String>> entry : mIncludes.entrySet()) {
397                     // File containing the <include>
398                     String includer = entry.getKey();
399                     // Files being <include>'ed by the above file
400                     List<String> included = entry.getValue();
401                     setIncludedBy(includer, included);
402                 }
403 
404                 return true;
405             }
406         } catch (CoreException e) {
407             AdtPlugin.log(e, "Can't read include settings");
408         }
409 
410         return false;
411     }
412 
413     // ----- File scanning -----
414 
415     /**
416      * Scan the whole project for XML layout resources that are performing includes.
417      */
scanProject()418     private void scanProject() {
419         ProjectResources resources = ResourceManager.getInstance().getProjectResources(mProject);
420         if (resources != null) {
421             Collection<ResourceItem> layouts = resources.getResourceItemsOfType(LAYOUT);
422             for (ResourceItem layout : layouts) {
423                 List<ResourceFile> sources = layout.getSourceFileList();
424                 for (ResourceFile source : sources) {
425                     updateFileIncludes(source, false);
426                 }
427             }
428 
429             return;
430         }
431     }
432 
433     /**
434      * Scans the given {@link ResourceFile} and if it is a layout resource, updates the
435      * includes in it.
436      *
437      * @param resourceFile the {@link ResourceFile} to be scanned for includes (doesn't
438      *            have to be only layout XML files; this method will filter the type)
439      * @param singleUpdate true if this is a single file being updated, false otherwise
440      *            (e.g. during initial project scanning)
441      * @return true if we updated the includes for the resource file
442      */
updateFileIncludes(ResourceFile resourceFile, boolean singleUpdate)443     private boolean updateFileIncludes(ResourceFile resourceFile, boolean singleUpdate) {
444         Collection<ResourceType> resourceTypes = resourceFile.getResourceTypes();
445         for (ResourceType type : resourceTypes) {
446             if (type == ResourceType.LAYOUT) {
447                 ensureInitialized();
448 
449                 List<String> includes = Collections.emptyList();
450                 if (resourceFile.getFile() instanceof IFileWrapper) {
451                     IFile file = ((IFileWrapper) resourceFile.getFile()).getIFile();
452 
453                     // See if we have an existing XML model for this file; if so, we can
454                     // just look directly at the parse tree
455                     boolean hadXmlModel = false;
456                     IStructuredModel model = null;
457                     try {
458                         IModelManager modelManager = StructuredModelManager.getModelManager();
459                         model = modelManager.getExistingModelForRead(file);
460                         if (model instanceof IDOMModel) {
461                             IDOMModel domModel = (IDOMModel) model;
462                             Document document = domModel.getDocument();
463                             includes = findIncludesInDocument(document);
464                             hadXmlModel = true;
465                         }
466                     } finally {
467                         if (model != null) {
468                             model.releaseFromRead();
469                         }
470                     }
471 
472                     // If no XML model we have to read the XML contents and (possibly) parse it.
473                     // The actual file may not exist anymore (e.g. when deleting a layout file
474                     // or when the workspace is out of sync.)
475                     if (!hadXmlModel) {
476                         String xml = AdtPlugin.readFile(file);
477                         if (xml != null) {
478                             includes = findIncludes(xml);
479                         }
480                     }
481                 } else {
482                     String xml = AdtPlugin.readFile(resourceFile);
483                     if (xml != null) {
484                         includes = findIncludes(xml);
485                     }
486                 }
487 
488                 String key = getMapKey(resourceFile);
489                 if (includes.equals(getIncludesFrom(key))) {
490                     // Common case -- so avoid doing settings flush etc
491                     return false;
492                 }
493 
494                 boolean detectCycles = singleUpdate;
495                 setIncluded(key, includes, detectCycles);
496 
497                 if (singleUpdate) {
498                     saveSettings();
499                 }
500 
501                 return true;
502             }
503         }
504 
505         return false;
506     }
507 
508     /**
509      * Finds the list of includes in the given XML content. It attempts quickly return
510      * empty if the file does not include any include tags; it does this by only parsing
511      * if it detects the string &lt;include in the file.
512      */
513     @VisibleForTesting
514     @NonNull
findIncludes(@onNull String xml)515     static List<String> findIncludes(@NonNull String xml) {
516         int index = xml.indexOf(ATTR_LAYOUT);
517         if (index != -1) {
518             return findIncludesInXml(xml);
519         }
520 
521         return Collections.emptyList();
522     }
523 
524     /**
525      * Parses the given XML content and extracts all the included URLs and returns them
526      *
527      * @param xml layout XML content to be parsed for includes
528      * @return a list of included urls, or null
529      */
530     @VisibleForTesting
531     @NonNull
findIncludesInXml(@onNull String xml)532     static List<String> findIncludesInXml(@NonNull String xml) {
533         Document document = DomUtilities.parseDocument(xml, false /*logParserErrors*/);
534         if (document != null) {
535             return findIncludesInDocument(document);
536         }
537 
538         return Collections.emptyList();
539     }
540 
541     /** Searches the given DOM document and returns the list of includes, if any */
542     @NonNull
findIncludesInDocument(@onNull Document document)543     private static List<String> findIncludesInDocument(@NonNull Document document) {
544         List<String> includes = findIncludesInDocument(document, null);
545         if (includes == null) {
546             includes = Collections.emptyList();
547         }
548         return includes;
549     }
550 
551     @Nullable
findIncludesInDocument(@onNull Node node, @Nullable List<String> urls)552     private static List<String> findIncludesInDocument(@NonNull Node node,
553             @Nullable List<String> urls) {
554         if (node.getNodeType() == Node.ELEMENT_NODE) {
555             String tag = node.getNodeName();
556             boolean isInclude = tag.equals(VIEW_INCLUDE);
557             boolean isFragment = tag.equals(VIEW_FRAGMENT);
558             if (isInclude || isFragment) {
559                 Element element = (Element) node;
560                 String url;
561                 if (isInclude) {
562                     url = element.getAttribute(ATTR_LAYOUT);
563                 } else {
564                     url = element.getAttributeNS(TOOLS_URI, ATTR_LAYOUT);
565                 }
566                 if (url.length() > 0) {
567                     String resourceName = urlToLocalResource(url);
568                     if (resourceName != null) {
569                         if (urls == null) {
570                             urls = new ArrayList<String>();
571                         }
572                         urls.add(resourceName);
573                     }
574                 }
575 
576             }
577         }
578 
579         NodeList children = node.getChildNodes();
580         for (int i = 0, n = children.getLength(); i < n; i++) {
581             urls = findIncludesInDocument(children.item(i), urls);
582         }
583 
584         return urls;
585     }
586 
587 
588     /**
589      * Returns the layout URL to a local resource name (provided the URL is a local
590      * resource, not something in @android etc.) Returns null otherwise.
591      */
urlToLocalResource(String url)592     private static String urlToLocalResource(String url) {
593         if (!url.startsWith("@")) { //$NON-NLS-1$
594             return null;
595         }
596         int typeEnd = url.indexOf('/', 1);
597         if (typeEnd == -1) {
598             return null;
599         }
600         int nameBegin = typeEnd + 1;
601         int typeBegin = 1;
602         int colon = url.lastIndexOf(':', typeEnd);
603         if (colon != -1) {
604             String packageName = url.substring(typeBegin, colon);
605             if ("android".equals(packageName)) { //$NON-NLS-1$
606                 // Don't want to point to non-local resources
607                 return null;
608             }
609 
610             typeBegin = colon + 1;
611             assert "layout".equals(url.substring(typeBegin, typeEnd)); //$NON-NLS-1$
612         }
613 
614         return url.substring(nameBegin);
615     }
616 
617     /**
618      * Record the list of included layouts from the given layout
619      *
620      * @param includer the layout including other layouts
621      * @param included the layouts that were included by the including layout
622      * @param detectCycles if true, check for cycles and report them as project errors
623      */
624     @VisibleForTesting
setIncluded(String includer, List<String> included, boolean detectCycles)625     /* package */ void setIncluded(String includer, List<String> included, boolean detectCycles) {
626         // Remove previously linked inverse mappings
627         List<String> oldIncludes = mIncludes.get(includer);
628         if (oldIncludes != null && oldIncludes.size() > 0) {
629             for (String includee : oldIncludes) {
630                 List<String> includers = mIncludedBy.get(includee);
631                 if (includers != null) {
632                     includers.remove(includer);
633                 }
634             }
635         }
636 
637         mIncludes.put(includer, included);
638         // Reverse mapping: for included items, point back to including file
639         setIncludedBy(includer, included);
640 
641         if (detectCycles) {
642             detectCycles(includer);
643         }
644     }
645 
646     /** Record the list of included layouts from the given layout */
setIncludedBy(String includer, List<String> included)647     private void setIncludedBy(String includer, List<String> included) {
648         for (String target : included) {
649             List<String> list = mIncludedBy.get(target);
650             if (list == null) {
651                 list = new ArrayList<String>(2); // We don't expect many includes
652                 mIncludedBy.put(target, list);
653             }
654             if (!list.contains(includer)) {
655                 list.add(includer);
656             }
657         }
658     }
659 
660     /** Start listening on project resources */
start()661     public static void start() {
662         assert sListener == null;
663         sListener = new ResourceListener();
664         ResourceManager.getInstance().addListener(sListener);
665     }
666 
667     /** Stop listening on project resources */
stop()668     public static void stop() {
669         assert sListener != null;
670         ResourceManager.getInstance().addListener(sListener);
671     }
672 
getMapKey(ResourceFile resourceFile)673     private static String getMapKey(ResourceFile resourceFile) {
674         IAbstractFile file = resourceFile.getFile();
675         String name = file.getName();
676         String folderName = file.getParentFolder().getName();
677         return getMapKey(folderName, name);
678     }
679 
getMapKey(IResource resourceFile)680     private static String getMapKey(IResource resourceFile) {
681         String folderName = resourceFile.getParent().getName();
682         String name = resourceFile.getName();
683         return getMapKey(folderName, name);
684     }
685 
getResourceName(IResource resourceFile)686     private static String getResourceName(IResource resourceFile) {
687         String name = resourceFile.getName();
688         int baseEnd = name.length() - EXT_XML.length() - 1; // -1: the dot
689         if (baseEnd > 0) {
690             name = name.substring(0, baseEnd);
691         }
692 
693         return name;
694     }
695 
getMapKey(String folderName, String name)696     private static String getMapKey(String folderName, String name) {
697         int baseEnd = name.length() - EXT_XML.length() - 1; // -1: the dot
698         if (baseEnd > 0) {
699             name = name.substring(0, baseEnd);
700         }
701 
702         // Create a map key for the given resource file
703         // This will map
704         //     /res/layout/foo.xml => "foo"
705         //     /res/layout-land/foo.xml => "-land/foo"
706 
707         if (FD_RES_LAYOUT.equals(folderName)) {
708             // Normal case -- keep just the basename
709             return name;
710         } else {
711             // Store the relative path from res/ on down, so
712             // /res/layout-land/foo.xml becomes "layout-land/foo"
713             //if (folderName.startsWith(FD_LAYOUT)) {
714             //    folderName = folderName.substring(FD_LAYOUT.length());
715             //}
716 
717             return folderName + WS_SEP + name;
718         }
719     }
720 
721     /** Listener of resource file saves, used to update layout inclusion data structures */
722     private static class ResourceListener implements IResourceListener {
723         @Override
fileChanged(IProject project, ResourceFile file, int eventType)724         public void fileChanged(IProject project, ResourceFile file, int eventType) {
725             if (sRefreshing) {
726                 return;
727             }
728 
729             if ((eventType & (CHANGED | ADDED | REMOVED | CONTENT)) == 0) {
730                 return;
731             }
732 
733             IncludeFinder finder = get(project);
734             if (finder != null) {
735                 if (finder.updateFileIncludes(file, true)) {
736                     finder.saveSettings();
737                 }
738             }
739         }
740 
741         @Override
folderChanged(IProject project, ResourceFolder folder, int eventType)742         public void folderChanged(IProject project, ResourceFolder folder, int eventType) {
743             // We only care about layout resource files
744         }
745     }
746 
747     // ----- Cycle detection -----
748 
detectCycles(String from)749     private void detectCycles(String from) {
750         // Perform DFS on the include graph and look for a cycle; if we find one, produce
751         // a chain of includes on the way back to show to the user
752         if (mIncludes.size() > 0) {
753             Set<String> visiting = new HashSet<String>(mIncludes.size());
754             String chain = dfs(from, visiting);
755             if (chain != null) {
756                 addError(from, chain);
757             } else {
758                 // Is there an existing error for us to clean up?
759                 removeErrors(from);
760             }
761         }
762     }
763 
764     /** Format to chain include cycles in: a=>b=>c=>d etc */
765     private final String CHAIN_FORMAT = "%1$s=>%2$s"; //$NON-NLS-1$
766 
dfs(String from, Set<String> visiting)767     private String dfs(String from, Set<String> visiting) {
768         visiting.add(from);
769 
770         List<String> includes = mIncludes.get(from);
771         if (includes != null && includes.size() > 0) {
772             for (String include : includes) {
773                 if (visiting.contains(include)) {
774                     return String.format(CHAIN_FORMAT, from, include);
775                 }
776                 String chain = dfs(include, visiting);
777                 if (chain != null) {
778                     return String.format(CHAIN_FORMAT, from, chain);
779                 }
780             }
781         }
782 
783         visiting.remove(from);
784 
785         return null;
786     }
787 
removeErrors(String from)788     private void removeErrors(String from) {
789         final IResource resource = findResource(from);
790         if (resource != null) {
791             try {
792                 final String markerId = IMarker.PROBLEM;
793 
794                 IMarker[] markers = resource.findMarkers(markerId, true, IResource.DEPTH_ZERO);
795 
796                 for (final IMarker marker : markers) {
797                     String tmpMsg = marker.getAttribute(IMarker.MESSAGE, null);
798                     if (tmpMsg == null || tmpMsg.startsWith(MESSAGE)) {
799                         // Remove
800                         runLater(new Runnable() {
801                             @Override
802                             public void run() {
803                                 try {
804                                     sRefreshing = true;
805                                     marker.delete();
806                                 } catch (CoreException e) {
807                                     AdtPlugin.log(e, "Can't delete problem marker");
808                                 } finally {
809                                     sRefreshing = false;
810                                 }
811                             }
812                         });
813                     }
814                 }
815             } catch (CoreException e) {
816                 // if we couldn't get the markers, then we just mark the file again
817                 // (since markerAlreadyExists is initialized to false, we do nothing)
818             }
819         }
820     }
821 
822     /** Error message for cycles */
823     private static final String MESSAGE = "Found cyclical <include> chain";
824 
addError(String from, String chain)825     private void addError(String from, String chain) {
826         final IResource resource = findResource(from);
827         if (resource != null) {
828             final String markerId = IMarker.PROBLEM;
829             final String message = String.format("%1$s: %2$s", MESSAGE, chain);
830             final int lineNumber = 1;
831             final int severity = IMarker.SEVERITY_ERROR;
832 
833             // check if there's a similar marker already, since aapt is launched twice
834             boolean markerAlreadyExists = false;
835             try {
836                 IMarker[] markers = resource.findMarkers(markerId, true, IResource.DEPTH_ZERO);
837 
838                 for (IMarker marker : markers) {
839                     int tmpLine = marker.getAttribute(IMarker.LINE_NUMBER, -1);
840                     if (tmpLine != lineNumber) {
841                         break;
842                     }
843 
844                     int tmpSeverity = marker.getAttribute(IMarker.SEVERITY, -1);
845                     if (tmpSeverity != severity) {
846                         break;
847                     }
848 
849                     String tmpMsg = marker.getAttribute(IMarker.MESSAGE, null);
850                     if (tmpMsg == null || tmpMsg.equals(message) == false) {
851                         break;
852                     }
853 
854                     // if we're here, all the marker attributes are equals, we found it
855                     // and exit
856                     markerAlreadyExists = true;
857                     break;
858                 }
859 
860             } catch (CoreException e) {
861                 // if we couldn't get the markers, then we just mark the file again
862                 // (since markerAlreadyExists is initialized to false, we do nothing)
863             }
864 
865             if (!markerAlreadyExists) {
866                 runLater(new Runnable() {
867                     @Override
868                     public void run() {
869                         try {
870                             sRefreshing = true;
871 
872                             // Adding a resource will force a refresh on the file;
873                             // ignore these updates
874                             BaseProjectHelper.markResource(resource, markerId, message, lineNumber,
875                                     severity);
876                         } finally {
877                             sRefreshing = false;
878                         }
879                     }
880                 });
881             }
882         }
883     }
884 
885     // FIXME: Find more standard Eclipse way to do this.
886     // We need to run marker registration/deletion "later", because when the include
887     // scanning is running it's in the middle of resource notification, so the IDE
888     // throws an exception
runLater(Runnable runnable)889     private static void runLater(Runnable runnable) {
890         Display display = Display.findDisplay(Thread.currentThread());
891         if (display != null) {
892             display.asyncExec(runnable);
893         } else {
894             AdtPlugin.log(IStatus.WARNING, "Could not find display");
895         }
896     }
897 
898     /**
899      * Finds the project resource for the given layout path
900      *
901      * @param from the resource name
902      * @return the {@link IResource}, or null if not found
903      */
findResource(String from)904     private IResource findResource(String from) {
905         final IResource resource = mProject.findMember(WS_LAYOUTS + WS_SEP + from + '.' + EXT_XML);
906         return resource;
907     }
908 
909     /**
910      * Creates a blank, project-less {@link IncludeFinder} <b>for use by unit tests
911      * only</b>
912      */
913     @VisibleForTesting
create()914     /* package */ static IncludeFinder create() {
915         IncludeFinder finder = new IncludeFinder(null);
916         finder.mIncludes = new HashMap<String, List<String>>();
917         finder.mIncludedBy = new HashMap<String, List<String>>();
918         return finder;
919     }
920 
921     /** A reference to a particular file in the project */
922     public static class Reference {
923         /** The unique id referencing the file, such as (for res/layout-land/main.xml)
924          * "layout-land/main") */
925         private final String mId;
926 
927         /** The project containing the file */
928         private final IProject mProject;
929 
930         /** The resource name of the file, such as (for res/layout/main.xml) "main" */
931         private String mName;
932 
933         /** Creates a new include reference */
Reference(IProject project, String id)934         private Reference(IProject project, String id) {
935             super();
936             mProject = project;
937             mId = id;
938         }
939 
940         /**
941          * Returns the id identifying the given file within the project
942          *
943          * @return the id identifying the given file within the project
944          */
getId()945         public String getId() {
946             return mId;
947         }
948 
949         /**
950          * Returns the {@link IFile} in the project for the given file. May return null if
951          * there is an error in locating the file or if the file no longer exists.
952          *
953          * @return the project file, or null
954          */
getFile()955         public IFile getFile() {
956             String reference = mId;
957             if (!reference.contains(WS_SEP)) {
958                 reference = FD_RES_LAYOUT + WS_SEP + reference;
959             }
960 
961             String projectPath = FD_RESOURCES + WS_SEP + reference + '.' + EXT_XML;
962             IResource member = mProject.findMember(projectPath);
963             if (member instanceof IFile) {
964                 return (IFile) member;
965             }
966 
967             return null;
968         }
969 
970         /**
971          * Returns a description of this reference, suitable to be shown to the user
972          *
973          * @return a display name for the reference
974          */
getDisplayName()975         public String getDisplayName() {
976             // The ID is deliberately kept in a pretty user-readable format but we could
977             // consider prepending layout/ on ids that don't have it (to make the display
978             // more uniform) or ripping out all layout[-constraint] prefixes out and
979             // instead prepending @ etc.
980             return mId;
981         }
982 
983         /**
984          * Returns the name of the reference, suitable for resource lookup. For example,
985          * for "res/layout/main.xml", as well as for "res/layout-land/main.xml", this
986          * would be "main".
987          *
988          * @return the resource name of the reference
989          */
getName()990         public String getName() {
991             if (mName == null) {
992                 mName = mId;
993                 int index = mName.lastIndexOf(WS_SEP);
994                 if (index != -1) {
995                     mName = mName.substring(index + 1);
996                 }
997             }
998 
999             return mName;
1000         }
1001 
1002         @Override
hashCode()1003         public int hashCode() {
1004             final int prime = 31;
1005             int result = 1;
1006             result = prime * result + ((mId == null) ? 0 : mId.hashCode());
1007             return result;
1008         }
1009 
1010         @Override
equals(Object obj)1011         public boolean equals(Object obj) {
1012             if (this == obj)
1013                 return true;
1014             if (obj == null)
1015                 return false;
1016             if (getClass() != obj.getClass())
1017                 return false;
1018             Reference other = (Reference) obj;
1019             if (mId == null) {
1020                 if (other.mId != null)
1021                     return false;
1022             } else if (!mId.equals(other.mId))
1023                 return false;
1024             return true;
1025         }
1026 
1027         @Override
toString()1028         public String toString() {
1029             return "Reference [getId()=" + getId() //$NON-NLS-1$
1030                     + ", getDisplayName()=" + getDisplayName() //$NON-NLS-1$
1031                     + ", getName()=" + getName() //$NON-NLS-1$
1032                     + ", getFile()=" + getFile() + "]"; //$NON-NLS-1$
1033         }
1034 
1035         /**
1036          * Creates a reference to the given file
1037          *
1038          * @param file the file to create a reference for
1039          * @return a reference to the given file
1040          */
create(IFile file)1041         public static Reference create(IFile file) {
1042             return new Reference(file.getProject(), getMapKey(file));
1043         }
1044 
1045         /**
1046          * Returns the resource name of this layout, such as {@code @layout/foo}.
1047          *
1048          * @return the resource name
1049          */
getResourceName()1050         public String getResourceName() {
1051             return '@' + FD_RES_LAYOUT + '/' + getName();
1052         }
1053     }
1054 
1055     /**
1056      * Returns a collection of layouts (expressed as resource names, such as
1057      * {@code @layout/foo} which would be invalid includes in the given layout
1058      * (because it would introduce a cycle)
1059      *
1060      * @param layout the layout file to check for cyclic dependencies from
1061      * @return a collection of layout resources which cannot be included from
1062      *         the given layout, never null
1063      */
getInvalidIncludes(IFile layout)1064     public Collection<String> getInvalidIncludes(IFile layout) {
1065         IProject project = layout.getProject();
1066         Reference self = Reference.create(layout);
1067 
1068         // Add anyone who transitively can reach this file via includes.
1069         LinkedList<Reference> queue = new LinkedList<Reference>();
1070         List<Reference> invalid = new ArrayList<Reference>();
1071         queue.add(self);
1072         invalid.add(self);
1073         Set<String> seen = new HashSet<String>();
1074         seen.add(self.getId());
1075         while (!queue.isEmpty()) {
1076             Reference reference = queue.removeFirst();
1077             String refId = reference.getId();
1078 
1079             // Look up both configuration specific includes as well as includes in the
1080             // base versions
1081             List<String> included = getIncludedBy(refId);
1082             if (refId.indexOf('/') != -1) {
1083                 List<String> baseIncluded = getIncludedBy(reference.getName());
1084                 if (included == null) {
1085                     included = baseIncluded;
1086                 } else if (baseIncluded != null) {
1087                     included = new ArrayList<String>(included);
1088                     included.addAll(baseIncluded);
1089                 }
1090             }
1091 
1092             if (included != null && included.size() > 0) {
1093                 for (String id : included) {
1094                     if (!seen.contains(id)) {
1095                         seen.add(id);
1096                         Reference ref = new Reference(project, id);
1097                         invalid.add(ref);
1098                         queue.addLast(ref);
1099                     }
1100                 }
1101             }
1102         }
1103 
1104         List<String> result = new ArrayList<String>();
1105         for (Reference reference : invalid) {
1106             result.add(reference.getResourceName());
1107         }
1108 
1109         return result;
1110     }
1111 }
1112