package fr.adrienbrault.idea.symfony2plugin.translation.dict; import com.intellij.codeInsight.lookup.LookupElement; import com.intellij.openapi.project.Project; import com.intellij.openapi.util.Pair; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.psi.PsiElement; import com.intellij.psi.PsiFile; import com.intellij.psi.PsiManager; import com.intellij.psi.search.GlobalSearchScope; import com.intellij.psi.util.PsiTreeUtil; import com.intellij.psi.xml.XmlFile; import com.intellij.psi.xml.XmlTag; import com.intellij.util.Consumer; import com.intellij.util.indexing.FileBasedIndex; import com.jetbrains.php.PhpIndex; import fr.adrienbrault.idea.symfony2plugin.stubs.SymfonyProcessors; import fr.adrienbrault.idea.symfony2plugin.stubs.indexes.TranslationStubIndex; import fr.adrienbrault.idea.symfony2plugin.translation.TranslationIndex; import fr.adrienbrault.idea.symfony2plugin.translation.TranslatorLookupElement; import fr.adrienbrault.idea.symfony2plugin.translation.collector.YamlTranslationCollector; import fr.adrienbrault.idea.symfony2plugin.translation.collector.YamlTranslationVistor; import fr.adrienbrault.idea.symfony2plugin.translation.parser.DomainMappings; import fr.adrienbrault.idea.symfony2plugin.translation.parser.TranslationStringMap; import fr.adrienbrault.idea.symfony2plugin.util.PsiElementUtils; import fr.adrienbrault.idea.symfony2plugin.util.service.ServiceXmlParserFactory; import fr.adrienbrault.idea.symfony2plugin.util.yaml.YamlKeyFinder; import org.apache.commons.lang.StringUtils; import org.jetbrains.annotations.NotNull; import org.jetbrains.yaml.YAMLFileType; import org.jetbrains.yaml.psi.YAMLDocument; import org.jetbrains.yaml.psi.YAMLFile; import org.jetbrains.yaml.psi.YAMLKeyValue; import org.jetbrains.yaml.psi.YAMLScalar; import org.w3c.dom.*; import org.xml.sax.SAXException; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; import javax.xml.xpath.XPathConstants; import javax.xml.xpath.XPathExpression; import javax.xml.xpath.XPathExpressionException; import javax.xml.xpath.XPathFactory; import java.io.IOException; import java.io.InputStream; import java.util.*; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; /** * @author Daniel Espendiller <daniel@espendiller.net> */ public class TranslationUtil { private static final String[] XLIFF_XPATH = { "//xliff/file/body/trans-unit/source", "//xliff/file/group/unit/segment/source", "//xliff/file/unit/segment/source" }; static public VirtualFile[] getDomainFilePsiElements(Project project, String domainName) { DomainMappings domainMappings = ServiceXmlParserFactory.getInstance(project, DomainMappings.class); List<VirtualFile> virtualFiles = new ArrayList<>(); for(DomainFileMap domain: domainMappings.getDomainFileMaps()) { if(domain.getDomain().equals(domainName)) { VirtualFile virtualFile = domain.getFile(); if(virtualFile != null) { virtualFiles.add(virtualFile); } } } return virtualFiles.toArray(new VirtualFile[virtualFiles.size()]); } public static PsiElement[] getTranslationPsiElements(final Project project, final String translationKey, final String domain) { List<PsiElement> psiFoundElements = new ArrayList<>(); List<VirtualFile> virtualFilesFound = new ArrayList<>(); // @TODO: completely remove this? support translation paths from service compiler // search for available domain files for(VirtualFile translationVirtualFile : getDomainFilePsiElements(project, domain)) { if(translationVirtualFile.getFileType() != YAMLFileType.YML) { continue; } PsiFile psiFile = PsiElementUtils.virtualFileToPsiFile(project, translationVirtualFile); if(psiFile instanceof YAMLFile) { PsiElement yamlDocu = PsiTreeUtil.findChildOfType(psiFile, YAMLDocument.class); if(yamlDocu != null) { YAMLKeyValue goToPsi = YamlKeyFinder.findKeyValueElement(yamlDocu, translationKey); if(goToPsi != null) { // multiline are line values are not resolve properly on psiElements use key as fallback target PsiElement valuePsiElement = goToPsi.getValue(); psiFoundElements.add(valuePsiElement != null ? valuePsiElement : goToPsi); virtualFilesFound.add(translationVirtualFile); } } } } // collect on index final YamlTranslationCollector translationCollector = (keyName, yamlKeyValue) -> { if (keyName.equals(translationKey)) { // multiline "line values" are not resolve properly on psiElements use key as fallback target PsiElement valuePsiElement = yamlKeyValue.getValue(); psiFoundElements.add(valuePsiElement != null ? valuePsiElement : yamlKeyValue); return false; } return true; }; FileBasedIndex.getInstance().getFilesWithKey(TranslationStubIndex.KEY, new HashSet<>(Collections.singletonList(domain)), virtualFile -> { // prevent duplicate targets and dont walk same file twice if(virtualFilesFound.contains(virtualFile)) { return true; } PsiFile psiFile = PsiManager.getInstance(project).findFile(virtualFile); if(psiFile == null) { return true; } if(psiFile instanceof YAMLFile) { YamlTranslationVistor.collectFileTranslations((YAMLFile) psiFile, translationCollector); } else if(isSupportedXlfFile(psiFile)) { // fine: xlf registered as XML file. try to find source value psiFoundElements.addAll(getTargetForXlfAsXmlFile((XmlFile) psiFile, translationKey)); } else if(("xlf".equalsIgnoreCase(virtualFile.getExtension()) || "xliff".equalsIgnoreCase(virtualFile.getExtension()))) { // xlf are plain text because not supported by jetbrains // for now we can only set file target psiFoundElements.addAll(FileBasedIndex.getInstance() .getValues(TranslationStubIndex.KEY, domain, GlobalSearchScope.filesScope(project, Collections.singletonList(virtualFile))).stream() .filter(string -> string.contains(translationKey)).map(string -> psiFile) .collect(Collectors.toList()) ); } return true; }, GlobalSearchScope.allScope(project)); return psiFoundElements.toArray(new PsiElement[psiFoundElements.size()]); } /** * Find targets for xlf files if registered as XML * * 1.2 xliff -> file -> body -> trans-unit -> source * 2.0 xliff -> file -> group -> unit -> segment -> source * 2.0 xliff -> file -> unit -> segment -> source */ @NotNull public static Collection<PsiElement> getTargetForXlfAsXmlFile(@NotNull XmlFile xmlFile, @NotNull String key) { XmlTag rootTag = xmlFile.getRootTag(); if(rootTag == null) { return Collections.emptyList(); } Collection<PsiElement> psiElements = new ArrayList<>(); // find source key Consumer<XmlTag> consumer = xmlTag -> { XmlTag source = xmlTag.findFirstSubTag("source"); if (source != null) { String text = source.getValue().getText(); if (key.equalsIgnoreCase(text)) { psiElements.add(source); } } }; for (XmlTag file : rootTag.findSubTags("file")) { // version="1.2" for (XmlTag body : file.findSubTags("body")) { for (XmlTag transUnit : body.findSubTags("trans-unit")) { consumer.consume(transUnit); // <trans-unit id="1" resname="title.test"> String resname = transUnit.getAttributeValue("resname"); if(resname != null && key.equals(resname)) { psiElements.add(transUnit); } } } // version="2.0" for (XmlTag group : file.findSubTags("group")) { for (XmlTag unit : group.findSubTags("unit")) { for (XmlTag segment : unit.findSubTags("segment")) { consumer.consume(segment); } } } // version="2.0" shortcut for (XmlTag unit : file.findSubTags("unit")) { for (XmlTag segment : unit.findSubTags("segment")) { consumer.consume(segment); } } } return psiElements; } public static boolean hasDomain(Project project, String domainName) { return TranslationIndex.getInstance(project).getTranslationMap().getDomainList().contains(domainName) || FileBasedIndex.getInstance().getValues( TranslationStubIndex.KEY, domainName, GlobalSearchScope.allScope(project) ).size() > 0; } public static boolean hasTranslationKey(@NotNull Project project, String keyName, String domainName) { if(!hasDomain(project, domainName)) { return false; } Set<String> domainMap = TranslationIndex.getInstance(project).getTranslationMap().getDomainMap(domainName); if(domainMap != null && domainMap.contains(keyName)) { return true; } for(Set<String> keys: FileBasedIndex.getInstance().getValues(TranslationStubIndex.KEY, domainName, GlobalSearchScope.allScope(project))){ if(keys.contains(keyName)) { return true; } } return false; } public static List<LookupElement> getTranslationLookupElementsOnDomain(Project project, String domainName) { Set<String> keySet = new HashSet<>(); List<Set<String>> test = FileBasedIndex.getInstance().getValues(TranslationStubIndex.KEY, domainName, GlobalSearchScope.allScope(project)); for(Set<String> keys: test ){ keySet.addAll(keys); } List<LookupElement> lookupElements = new ArrayList<>(); TranslationStringMap map = TranslationIndex.getInstance(project).getTranslationMap(); Collection<String> domainMap = map.getDomainMap(domainName); if(domainMap != null) { // php translation parser; are not weak and valid keys for(String stringId : domainMap) { lookupElements.add(new TranslatorLookupElement(stringId, domainName)); } // attach weak translations keys on file index for(String stringId : keySet) { if(!domainMap.contains(stringId)) { lookupElements.add(new TranslatorLookupElement(stringId, domainName, true)); } } return lookupElements; } // fallback on index for(String stringId : keySet) { lookupElements.add(new TranslatorLookupElement(stringId, domainName, true)); } return lookupElements; } @NotNull public static List<LookupElement> getTranslationDomainLookupElements(Project project) { List<LookupElement> lookupElements = new ArrayList<>(); // domains on complied file TranslationStringMap map = TranslationIndex.getInstance(project).getTranslationMap(); Set<String> domainList = map.getDomainList(); for(String domainKey : domainList) { lookupElements.add(new TranslatorLookupElement(domainKey, domainKey)); } SymfonyProcessors.CollectProjectUniqueKeysStrong projectUniqueKeysStrong = new SymfonyProcessors.CollectProjectUniqueKeysStrong(project, TranslationStubIndex.KEY, domainList); FileBasedIndex.getInstance().processAllKeys(TranslationStubIndex.KEY, projectUniqueKeysStrong, project); // attach index domains as weak one for(String domainKey: projectUniqueKeysStrong.getResult()) { if(!domainList.contains(domainKey)) { lookupElements.add(new TranslatorLookupElement(domainKey, domainKey, true)); } } return lookupElements; } public static List<PsiFile> getDomainPsiFiles(final Project project, String domainName) { final List<PsiFile> results = new ArrayList<>(); final List<VirtualFile> uniqueFileList = new ArrayList<>(); // get translation files from compiler for(VirtualFile virtualFile : TranslationUtil.getDomainFilePsiElements(project, domainName)) { PsiFile psiFile = PsiManager.getInstance(project).findFile(virtualFile); if(psiFile != null) { uniqueFileList.add(virtualFile); results.add(psiFile); } } FileBasedIndex.getInstance().getFilesWithKey(TranslationStubIndex.KEY, new HashSet<>(Collections.singletonList(domainName)), virtualFile -> { if(uniqueFileList.contains(virtualFile)) { return true; } PsiFile psiFile = PsiManager.getInstance(project).findFile(virtualFile); if(psiFile != null) { uniqueFileList.add(virtualFile); results.add(psiFile); } return true; }, PhpIndex.getInstance(project).getSearchScope()); return results; } @NotNull public static Set<String> getXliffTranslations(@NotNull InputStream content) { Set<String> set = new HashSet<>(); visitXliffTranslations(content, pair -> set.add(pair.getFirst())); return set; } private static void visitXliffTranslations(@NotNull InputStream content, @NotNull Consumer<Pair<String, Node>> consumer) { Document document; try { DocumentBuilder documentBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder(); document = documentBuilder.parse(content); } catch (ParserConfigurationException | SAXException | IOException e) { return; } if(document == null) { return; } for (String s : XLIFF_XPATH) { visitNodes(s, document, consumer); } } public static boolean isSupportedXlfFile(@NotNull PsiFile psiFile) { if(!(psiFile instanceof XmlFile)) { return false; } String extension = psiFile.getVirtualFile().getExtension(); return "xlf".equalsIgnoreCase(extension) || "xliff".equalsIgnoreCase(extension); } /** * Translation placeholder extraction: * "%limit%", "{{ limit }}", "{{limit}}", * "@username", "!username", "%username" */ @NotNull public static Set<String> getPlaceholderFromTranslation(@NotNull String text) { Set<String> placeholder = new HashSet<>(); // best practise Matcher matcher = Pattern.compile("(%[^%^\\s]*%)").matcher(text); while(matcher.find()){ placeholder.add(matcher.group(1)); } // validator matcher = Pattern.compile("(\\{\\{\\s*[^{]*\\s*}})").matcher(text); while(matcher.find()){ placeholder.add(matcher.group(1)); } // Drupal matcher = Pattern.compile("([@|!|%][^\\s][\\w-]*)[\\s]*").matcher(text); while(matcher.find()){ placeholder.add(matcher.group(1)); } return placeholder; } /** * Extract common placeholder pattern from translation content */ @NotNull public static Set<String> getPlaceholderFromTranslation(@NotNull Project project, @NotNull String key, @NotNull String domain) { Set<String> placeholder = new HashSet<>(); Set<VirtualFile> visitedXlf = new HashSet<>(); for (PsiElement element : TranslationUtil.getTranslationPsiElements(project, key, domain)) { if (element instanceof YAMLScalar) { String textValue = ((YAMLScalar) element).getTextValue(); if(StringUtils.isBlank(textValue)) { continue; } placeholder.addAll( TranslationUtil.getPlaceholderFromTranslation(textValue) ); } else if("xlf".equalsIgnoreCase(element.getContainingFile().getVirtualFile().getExtension()) || "xliff".equalsIgnoreCase(element.getContainingFile().getVirtualFile().getExtension())) { VirtualFile virtualFile = element.getContainingFile().getVirtualFile(); // visiting on file scope because we dont rely on xlf and xliff registered as XML file // dont visit file twice if(!visitedXlf.contains(virtualFile)) { try { visitXliffTranslations( element.getContainingFile().getVirtualFile().getInputStream(), new MyXlfTranslationConsumer(placeholder, key) ); } catch (IOException ignored) { } } visitedXlf.add(virtualFile); } } return placeholder; } private static void visitNodes(@NotNull String xpath, @NotNull Document document, @NotNull Consumer<Pair<String, Node>> consumer) { Object result; try { // @TODO: xpath should not use "file/body" XPathExpression xPathExpr = XPathFactory.newInstance().newXPath().compile(xpath); result = xPathExpr.evaluate(document, XPathConstants.NODESET); } catch (XPathExpressionException e) { return; } if(!(result instanceof NodeList)) { return; } NodeList nodeList = (NodeList) result; for (int i = 0; i < nodeList.getLength(); i++) { Element node = (Element) nodeList.item(i); String textContent = node.getTextContent(); if(org.apache.commons.lang.StringUtils.isNotBlank(textContent)) { consumer.consume(Pair.create(textContent, node)); } // <trans-unit id="1" resname="title.test"> Node transUnitNode = node.getParentNode(); if(transUnitNode != null) { NamedNodeMap attributes = transUnitNode.getAttributes(); if(attributes != null) { Node resname = attributes.getNamedItem("resname"); if(resname != null) { String textContentResname = resname.getTextContent(); if(textContentResname != null && StringUtils.isNotBlank(textContentResname)) { consumer.consume(Pair.create(textContentResname, node)); } } } } } } /** * <trans-unit id="29"> * <source>foo</source> * <target>foo</target> * </trans-unit> */ private static class MyXlfTranslationConsumer implements Consumer<Pair<String, Node>> { @NotNull private final Set<String> placeholder; @NotNull private final String key; MyXlfTranslationConsumer(@NotNull Set<String> placeholder, @NotNull String key) { this.placeholder = placeholder; this.key = key; } @Override public void consume(Pair<String, Node> pair) { if(!(pair.getSecond() instanceof Element) || !"source".equalsIgnoreCase(pair.getSecond().getNodeName())) { return; } Element source = (Element) pair.getSecond(); if(!key.equalsIgnoreCase(source.getTextContent())) { return; } visitNodeText(source); Node transUnit = source.getParentNode(); if(transUnit instanceof Element) { NodeList target = ((Element) transUnit).getElementsByTagName("target"); if(target.getLength() > 0) { visitNodeText(target.item(0)); } } } private void visitNodeText(@NotNull Node target) { String nodeValue = target.getTextContent(); if(StringUtils.isNotBlank(nodeValue)) { placeholder.addAll( TranslationUtil.getPlaceholderFromTranslation(nodeValue) ); } } } }