packages feed

glean-0.1.0.0: glean/lang/scip/Data/SCIP/Angle.hs

{-
  Copyright (c) Meta Platforms, Inc. and affiliates.
  All rights reserved.

  This source code is licensed under the BSD-style license found in the
  LICENSE file in the root directory of this source tree.
-}

{-

Convert Data.SCIP into glean/schema/lsif.angle-compatible data via JSON.

Note: this module generates Angle but has no dependency on the Glean LSIF
schema (which it targets), to make developer iteration quicker.

-}

{-# LANGUAGE DataKinds         #-}
{-# LANGUAGE OverloadedStrings #-}

module Data.SCIP.Angle (
    scipToAngle,
    SCIP.LanguageId(..)
  ) where

import Control.Monad
import Lens.Micro ((^.))
import Data.Bits ( Bits(testBit) )
import Data.Maybe ( catMaybes, fromMaybe )
import Data.Function ((&))
import Data.Text ( Text )
import qualified Data.Text as Text
import Data.Set ( Set )
import qualified Data.Set as Set
import qualified Data.Aeson as Aeson
import Data.Int ( Int32, Int64 )
import qualified Data.ByteString as B
import Data.HashMap.Strict ( HashMap )
import qualified Data.HashMap.Strict as HashMap
import Data.Map.Strict ( Map, ( !? ) )
import qualified Data.Map.Strict as Map
import Control.Monad.State.Strict
import qualified Data.ProtoLens as Proto
import qualified Data.Vector as V
import Data.Aeson

import qualified Proto.Scip as Scip
import qualified Proto.Scip_Fields as Scip

import qualified Data.LSIF.Gen as SCIP

{-

Debug scip files directly via protoc. Assuming you the scip.proto file handy
From https://github.com/sourcegraph/scip/blob/main/scip.proto

> protoc --decode scip.Index scip.proto  < index.scip

The approach is to faithfully capture SCIP keyed by scip.Symbol.
Then derive xref relationships (and connect hovers to definitions),
in the Glean side

-}

type Parse a = forall m . Monad m => StateT Env m a

data StringPredicate =
  Symbol
  | LocalName
  | File
  | DisplayName
  deriving (Eq, Ord)

data Env = Env {
    -- unique supply for new Glean fact identifiers
    unique :: {-# UNPACK #-}!Int64,

    -- Hashmaps from any raw text fact to the id we generated.
    -- Used to do a bit of sharing before emitting to Glean.
    -- We maintain one hashmap per string predicate.
    factId :: !(Map StringPredicate (HashMap Text SCIP.Id))
  }

emptyState :: Env
emptyState = Env
  1 {- cannot use 0 as a fact id -}
  Map.empty

--
-- Scip doesn't number facts, but it is still useful for us to do so,
-- to get more sharing in the output json
--
nextId :: Parse SCIP.Id
nextId = do
  !i <- gets unique
  modify $ \e -> e { unique = i + 1 }
  return (SCIP.Id i)

setDefFact :: StringPredicate -> Text -> SCIP.Id -> Parse ()
setDefFact pred val i = modify $ \e ->
  let m = factId e !? pred & fromMaybe HashMap.empty & HashMap.insert val i in
  e { factId = Map.insert pred m (factId e)}

getDefFactId :: StringPredicate -> Text -> Parse (Maybe SCIP.Id)
getDefFactId pred sym = do
  m <- gets factId
  pure $ m !? pred >>= HashMap.lookup sym

-- | Make a fresh name or return an existing one if we've seen it
getOrSetFact :: StringPredicate -> Text -> Parse (SCIP.Id, Bool)
getOrSetFact pred sym = do
  mId <- getDefFactId pred sym
  case mId of
    Nothing -> do
      id_ <- nextId
      setDefFact pred sym id_
      return (id_, False)
    Just id_ -> return (id_, True)

--
-- | Parse scip.proto into JSON-encoded Angle facts for the scip.angle schema
--
-- Uses the proto-lens interface to scip.proto
--
scipToAngle
  :: Maybe SCIP.LanguageId
  -> Bool
  -> Maybe FilePath
  -> Maybe FilePath
  -> B.ByteString
  -> Aeson.Value
scipToAngle mlang inferLanguage mPathPrefix mStripPrefix scip =
  Aeson.Array $ V.fromList $
    SCIP.generateSCIPJSON (SCIP.insertPredicateMap HashMap.empty result)
  where
    (result,_) = runState (runTranslate mlang
      inferLanguage mPathPrefix mStripPrefix scip) emptyState

-- | First pass, grab all the occurences with _role := Definition
-- build up symbol string -> fact id for all defs
runTranslate
  :: Maybe SCIP.LanguageId
  -> Bool
  -> Maybe FilePath
  -> Maybe FilePath
  -> B.ByteString
  -> Parse [SCIP.Predicate]
runTranslate mlang inferLanguage mPathPrefix mStripPrefix scip =
  case Proto.decodeMessage scip of
    Left err -> error err
    Right (v :: Scip.Index) -> do
      a <- decodeScipMetadata (v ^. Scip.metadata)
      bs <- mapM
        (decodeScipDoc mlang inferLanguage mPathPrefix mStripPrefix)
        (v ^. Scip.documents)
      return (a <> concat bs)

--
-- Each document has a repo-relative filepath, defs and refs (symbols and
-- occurences). Generate fact ids and record symbol id facts as we find them,
-- then cross-reference with occurences in second pass
--
decodeScipDoc
  :: Maybe SCIP.LanguageId
  -> Bool
  -> Maybe FilePath
  -> Maybe FilePath
  -> Scip.Document
  -> Parse [SCIP.Predicate]
decodeScipDoc mlang inferLanguage mPathPrefix mStripPrefix doc = do
  srcFileId <- nextId
  let filepath0 = doc ^. Scip.relativePath
      -- first, strip any matching prefix
      filepath1 = case Text.pack <$> mStripPrefix of
        Nothing -> filepath0
        Just prefix -> fromMaybe filepath0 $ Text.stripPrefix prefix filepath0
      -- and maybe prepend a new prefix
      filepath = case Text.pack <$> mPathPrefix of
        Nothing -> filepath1
        Just prefix -> prefix <> filepath1
  setDefFact File filepath srcFileId
  let srcFile = SCIP.srcFile srcFileId filepath
  langFileId <- nextId
  let parseLang = SCIP.parseLanguage (doc ^. Scip.language)
      langEnum = fromEnum $ case parseLang of
                SCIP.UnknownLanguage
                  -- if --infer-language , look at the suffix
                  | inferLanguage
                  , Just langId <- fileLanguageOf filepath
                  -> langId
                  -- otherwise if --language, assume that's correct
                  | Just langId <- mlang -> langId -- use default if present
                  -- otherwise its really unknown
                  | otherwise -> SCIP.UnknownLanguage
                x -> x -- scip document provides the language
  fileLang <- SCIP.predicateId "scip.FileLanguage" langFileId
    [ "file" .= srcFileId
    , "language" .= langEnum
    ]
  occs <- mapM (decodeScipOccurence srcFileId filepath)
      (doc ^. Scip.occurrences)
  infos <- mapM (decodeScipInfo filepath) (doc ^. Scip.symbols)
  return (srcFile : fileLang <> concat (occs <> infos))

-- We really don't want to do a general purpose language detector
-- but rely on the indexer knowing things. For the Java/Kotlin case,
-- files are frequently intermingled in the same build so we can't
-- decide a priori which language is being indexed
fileLanguageOf :: Text -> Maybe  SCIP.LanguageId
fileLanguageOf filepath
  | "kt" `Text.isSuffixOf` filepath = Just SCIP.Kotlin
  | "java" `Text.isSuffixOf` filepath = Just SCIP.Java
  | otherwise = Nothing

decodeScipInfo :: Text -> Scip.SymbolInformation -> Parse [SCIP.Predicate]
decodeScipInfo filepath info = do
  (docIds, docFacts) <- unzip <$> forM scipDocs (\docStr -> do
    docId <- nextId
    return (docId, SCIP.Predicate "scip.Documentation" [
            object [ SCIP.factId docId, "key" .= Text.strip docStr ]
          ]))
  mSymId <- getSymbolId scipSymbol filepath
  symDocFacts <- case mSymId of
    Nothing -> return []
    Just symId -> forM docIds (\docId ->
        SCIP.predicateId "scip.SymbolDocumentation" docId [
          "symbol" .= symId,
          "docs" .= docId
        ])
  displayNameFacts <- case mSymId of
    Nothing -> return []
    Just symId -> case scipDisplayName of
      "" -> return []
      _ ->  displayNameFacts scipDisplayName symId
  relationshipsFacts <- case mSymId of
    Nothing -> return []
    Just symId -> forM scipRelationshps (\rel ->
        if rel ^. Scip.isImplementation then do
          let implementedSymbol = rel ^. Scip.symbol
          implementedSymbolId <- getSymbolId implementedSymbol filepath
          case implementedSymbolId of
            Nothing -> return []
            Just implementedSymbolId ->
              SCIP.predicate "scip.IsImplementation" [
                  "symbol" .= symId,
                  "implemented" .= implementedSymbolId
                ]
        else
          pure [] )
  return
    ( docFacts
    <> concat symDocFacts
    <> displayNameFacts
    <> concat relationshipsFacts )

  where
    scipSymbol = info ^. Scip.symbol
    scipDocs = info ^. Scip.documentation
    scipDisplayName = info ^. Scip.displayName
    scipRelationshps = info ^. Scip.relationships

getSymbolId :: Text -> Text -> Parse (Maybe SCIP.Id)
getSymbolId symbol filepath = do
  let eSym = symbolFromString symbol
  let qualifiedSymbol = case eSym of
        Left err -> error(show err)
        Right (Local _) -> filepath <> "/" <> symbol
        Right (Global {}) -> symbol
  getDefFactId Symbol qualifiedSymbol


displayNameFacts :: Text -> SCIP.Id -> Parse [SCIP.Predicate]
displayNameFacts scipDisplayName symId = do
  (displayNameId, seenDisplayName) <- getOrSetFact DisplayName scipDisplayName
  let displayNameFact =
        ([ SCIP.Predicate "scip.DisplayName"
              [object [SCIP.factId displayNameId, "key" .= scipDisplayName]] |
          not seenDisplayName ])
  symbolDisplayNameFact <-
    SCIP.predicate
      "scip.DisplayNameSymbol"
      ["symbol" .= symId, "displayName" .= displayNameId]
  return (displayNameFact <> symbolDisplayNameFact)


-- | An occurence of a symbol in a given document the optional symbol role
-- will tell us if it is an xref or a def or other
decodeScipOccurence
  :: SCIP.Id
  -> Text
  -> Scip.Occurrence
  -> Parse [SCIP.Predicate]
decodeScipOccurence fileId filepath occ = do
    fileRangeId <- nextId
    fileRange <- SCIP.predicateId "scip.FileRange" fileRangeId
      [ "file" .= fileId
      , "range" .= decodeScipRange scipRange
      ]
    let eSym = symbolFromString scipSymbol
    symbolFacts <- case eSym of
          Left err -> error (show err) -- Can't handle this symbol format
          Right (Local _) -> decodeLocalOccurence filepath scipSymbol symRoles
              fileRangeId
          Right Global{..} -> decodeGlobalOccurence scipSymbol symRoles
              fileRangeId descriptor
    return (fileRange <> symbolFacts)
  where
    scipRange = occ ^. Scip.range
    scipSymbol = occ ^. Scip.symbol
    symRoles = toSymbolRole (occ ^. Scip.symbolRoles)

decodeGlobalOccurence
  :: Text -> Set Scip.SymbolRole -> SCIP.Id -> Descriptor
  -> Parse [SCIP.Predicate]
decodeGlobalOccurence scipSymbol symRoles fileRangeId Descriptor{..} = do
  (symbolId, seenSymbol) <- getOrSetFact Symbol scipSymbol
  let symbolFact :: [SCIP.Predicate]
        | seenSymbol = []
        | otherwise = pure $
            SCIP.Predicate "scip.Symbol" [
                object [ SCIP.factId symbolId, "key" .= scipSymbol ]
            ]
  let roleFact :: [[SCIP.Predicate]] = if Scip.Definition `Set.member` symRoles
        then SCIP.predicate "scip.Definition" [
            "symbol" .= symbolId,
            "location" .= fileRangeId
          ]
        else SCIP.predicate "scip.Reference" [
            "symbol" .= symbolId,
            "location" .= fileRangeId
          ]
  (nameId, seenName) <- getOrSetFact LocalName text
  let nameFact :: [SCIP.Predicate]
        | seenName = []
        | otherwise = pure $
            SCIP.Predicate "scip.LocalName" [
                object [ SCIP.factId nameId, "key" .= text ]
            ]
  let symbolNameFact :: [[SCIP.Predicate]]
        | seenSymbol = []
        | otherwise = SCIP.predicate "scip.SymbolName" [
              "symbol" .= symbolId,
              "name" .= nameId
           ]
  let kindFact :: [SCIP.Predicate] = concat $
        case SCIP.kindFromSuffix suffix of
          SCIP.SkUnknown -> [[]]
          kind -> SCIP.predicate "scip.SymbolKind" [
              "symbol" .= symbolId,
              "kind" .= fromEnum kind
            ]
  return $ symbolFact <> concat roleFact <> nameFact <>
            concat symbolNameFact <> kindFact

decodeLocalOccurence
  :: Text
  -> Text
  -> Set Scip.SymbolRole
  -> SCIP.Id
  -> Parse [SCIP.Predicate]
decodeLocalOccurence filepath localSymbol symRoles fileRangeId = do
  let qualifiedSymbol = filepath <> "/" <> localSymbol
  (symbolId, seenSymbol) <- getOrSetFact Symbol qualifiedSymbol
  let symbolFact :: [SCIP.Predicate]
        | seenSymbol = []
        | otherwise = pure $
            SCIP.Predicate "scip.Symbol" [
                object [ SCIP.factId symbolId, "key" .= qualifiedSymbol ]
            ]
  let roleFact :: [[SCIP.Predicate]] = if Scip.Definition `Set.member` symRoles
        then SCIP.predicate "scip.Definition" [
            "symbol" .= symbolId,
            "location" .= fileRangeId
          ]
        else SCIP.predicate "scip.Reference" [
            "symbol" .= symbolId,
            "location" .= fileRangeId
          ]
  (nameId, seenName) <- getOrSetFact LocalName localSymbol
  let nameFact :: [SCIP.Predicate]
        | seenName = []
        | otherwise = pure $
            SCIP.Predicate "scip.LocalName" [
                object [ SCIP.factId nameId, "key" .= localSymbol ]
            ]
  let symbolNameFact :: [[SCIP.Predicate]]
        | seenSymbol = []
        | otherwise = SCIP.predicate "scip.SymbolName" [
              "symbol" .= symbolId,
              "name" .= nameId
           ]
  let kindFact :: [[SCIP.Predicate]]
        | seenSymbol = []
        | otherwise = SCIP.predicate "scip.SymbolKind" [
              "symbol" .= symbolId,
              -- TODO: this could be any SymbolInformation.Kind
              "kind" .= fromEnum SCIP.SkVariable
            ]
  return $ symbolFact <> concat roleFact <> nameFact <>
            concat symbolNameFact <> concat kindFact

-- | For sharding we might want to take a repo-relative anchor here
-- as it potentially differs to project root when combining SCIP files
decodeScipMetadata :: Scip.Metadata -> Parse [SCIP.Predicate]
decodeScipMetadata v = SCIP.predicate "scip.Metadata" $
  [ "version" .= fromEnum (v ^. Scip.version)
  , "textEncoding" .= fromEnum (v ^. Scip.textDocumentEncoding)
  ] ++ (case v ^. Scip.maybe'toolInfo of
          Nothing -> []
          Just ti ->
            ["toolInfo" .= object [
              "toolName" .= (ti ^. Scip.name),
              "toolArgs" .= (ti ^. Scip.arguments),
              "version" .= (ti ^. Scip.version)
            ]])

-- scip ranges are int32
toNat :: Int32 -> Int64
toNat = fromIntegral

-- [startLine, startCharacter, endCharacter]`. The end line
--  is inferred to have the same value as the start line.
decodeScipRange :: [Int32] -> Aeson.Value
decodeScipRange [lineBegin,colBegin,colEnd] =
  SCIP.toRange (SCIP.Range
    (SCIP.Position (toNat lineBegin) (toNat colBegin))
    (SCIP.Position (toNat lineBegin) (toNat colEnd))) -- n.b
-- : `[startLine, startCharacter, endLine, endCharacter]`
decodeScipRange [lineBegin,colBegin,lineEnd,colEnd] =
  SCIP.toRange (SCIP.Range
    (SCIP.Position (toNat lineBegin) (toNat colBegin))
    (SCIP.Position (toNat lineEnd) (toNat colEnd)))
decodeScipRange range = error $
  "decodeScipRange: unexpected range format: " <> show range

{-

//   <symbol>               ::= <scheme> ' ' <package> ' ' { <descriptor> }
//      | 'local ' <local-id>
//   <package>              ::= <manager> ' ' <package-name> ' ' <version>
//   <scheme>               ::= any UTF-8, escape spaces with double space.
//   <manager>              ::= same as above, use the placeholder '.' to
//          indicate an empty value
//   <package-name>         ::= same as above
//   <version>              ::= same as above
//   <descriptor>           ::= <package> | <type> | <term> | <method>
//       | <type-parameter> | <parameter> | <meta>
//   <package>              ::= <name> '/'
//   <type>                 ::= <name> '#'
//   <term>                 ::= <name> '.'
//   <meta>                 ::= <name> ':'
//   <method>               ::= <name> '(' <method-disambiguator> ').'
//   <type-parameter>       ::= '[' <name> ']'
//   <parameter>            ::= '(' <name> ')'
//   <name>                 ::= <identifier>
//   <method-disambiguator> ::= <simple-identifier>
//   <identifier>           ::= <simple-identifier> | <escaped-identifier>
//   <simple-identifier>    ::= { <identifier-character> }
//   <identifier-character> ::= '_' | '+' | '-' | '$' | ASCII letter or digit
//   <escaped-identifier>   ::= '`' { <escaped-character> } '`'
//   <escaped-characters>   ::= any UTF-8 character, escape backticks with
//   d        ouble backtick.

-}

data ScipSymbol
  = Local !Text
  | Global
      { scheme :: !Text
      , package :: !Package
      , descriptor :: !Descriptor -- grammar says these can be repetitions...
      }

data Package = Package
  { manager :: !Text
  , pkgname :: !Text
  , version :: !Text
}

data Descriptor = Descriptor
  { text:: !Text
  , suffix :: !SCIP.Suffix
  }

--
-- Parser and ADT for Scip.Symbol strings.
-- These are well structured but stored as strings for reasons
--
-- https://github.com/sourcegraph/scip/blob/main/scip.proto#L81
-- e.g.
--
-- >  "scip-typescript npm new.docusaurus.io 2.0.0-beta.17 \
-- >      functionUtils/`playgroundUtils.ts`/CookieName."
--
symbolFromString :: Text -> Either Text ScipSymbol
symbolFromString str
  -- 'local ' <local-id>
  | ("local", localId) <- split normalStr
  = Right $ Local localId
  --  <scheme> ' ' <package> ' ' { <descriptor> }
  | (scheme, rest1) <- split normalStr
  -- <package> ::= <manager> ' ' <package-name> ' ' <version>
  , (manager, rest2) <- split rest1
  , (pkgname, rest3) <- split rest2
  , (version, symStrs) <- split rest3
  , (text, suffix) <- SCIP.parseSuffix symStrs
  = Right $ Global scheme Package{..} Descriptor {..}

  where
    -- we normalize space-escaping in identifiers with underscore
    normalStr = Text.intercalate "_" (Text.splitOn doubleSpace str)
      where doubleSpace = "  "

    split xs = case Text.breakOn " " xs of
      (tok, rest) -> (tok, if Text.null rest then rest else Text.tail rest)

{-
bitmask in i32

enum SymbolRole {
  // unused
  UnspecifiedSymbolRole = 0;
  // Is the symbol defined here? If not, then this is a symbol reference.
  Definition = 0x1;
  // Is the symbol imported here?
  Import = 0x2;
  // Is the symbol written here?
  WriteAccess = 0x4;
  // Is the symbol read here?
  ReadAccess = 0x8;
  // Is the symbol in generated code?
  Generated = 0x10;
  // Is the symbol in test code?
  Test = 0x20;
-}
toSymbolRole :: Int32 -> Set Scip.SymbolRole
toSymbolRole i = Set.fromList $ catMaybes
  [ Nothing -- if i == 0 then Just Scip.UnspecifiedSymbolRole else Nothing
  , has 0 Scip.Definition
  , has 1 Scip.Import
  , has 2 Scip.WriteAccess
  , has 3 Scip.ReadAccess
  , has 4 Scip.Generated
  , has 5 Scip.Test
  ]
  where
    has n ty
      | i `testBit` n = Just ty
      | otherwise = Nothing