packages feed

tricorder-0.2.0.0: src/Tricorder/SourceLookup/Slice.hs

-- | A pure, lexical source slicer.
--
-- It is deliberately /lexical/. It reasons about column-0 anchors, leading
-- keywords, and blank-line boundaries; it never builds a real Haskell AST. That
-- keeps it robust to CPP, unknown language extensions, and exotic syntax: a
-- shape it cannot make sense of simply yields 'Nothing', and the caller falls
-- back to the whole module (or reports the symbol as not found). It never
-- throws.
module Tricorder.SourceLookup.Slice
    ( sliceSymbol
    ) where

import Data.Char (isAlphaNum, isSpace, isUpper)

import Data.List qualified as List
import Data.Text qualified as T


-- | Slice the declaration that introduces @symbol@ from @source@.
--
-- Returns the top-level declaration that introduces the symbol: the declaration
-- head, any type signature directly above it, and the contiguous doc-comment
-- block above that — terminating at the blank line or the next top-level
-- declaration that ends it. 'Nothing' when no introducing declaration is found.
sliceSymbol :: Text -> Text -> Maybe Text
sliceSymbol symbol source
    | T.null symbol = Nothing
    | otherwise =
        let ls = T.lines source
        in  if isTypeSymbol symbol then
                sliceType symbol ls <|> sliceConstructor symbol ls
            else
                sliceValue symbol ls


-- | Evaluate whether the symbol references a type-level entity.
isTypeSymbol :: Text -> Bool
isTypeSymbol = maybe False (isUpper . fst) . T.uncons


-- ── Value bindings ─────────────────────────────────────────────────────────

-- | Slice the value binding introducing @name@ (a function, CAF, or operator).
sliceValue :: Text -> [Text] -> Maybe Text
sliceValue name ls =
    (\i -> expandAt (belongsToValue name) i ls) <$> List.findIndex (introducesValue name) ls


-- | Whether a line introduces the value @name@ at column 0 — its type
-- signature, an equation head, or a bare binding. Operators are matched in
-- their parenthesised @(op)@ form.
introducesValue :: Text -> Text -> Bool
introducesValue name line =
    isCol0 line
        && ( leadingIdent line == name
                || ("(" <> name <> ")") `T.isPrefixOf` line
           )


-- | Whether a column-0 line below the anchor still belongs to the value binding
-- for @name@: a further equation or signature (same leading identifier or
-- @(name)@ head), or — for an operator — an infix equation whose left-hand side
-- uses it (e.g. @a \<+\> b = …@, which leads with the argument rather than the
-- operator).
belongsToValue :: Text -> Text -> Bool
belongsToValue name line =
    introducesValue name line
        || (isOperatorName name && usesOperatorInHead name line)


-- | Whether @line@ is an equation whose left-hand side applies operator @name@
-- infix. The head is the text before the first @=@; the operator must appear
-- there as a whitespace-delimited token, so an unrelated binding that merely
-- mentions the operator in its /body/ (@merge x y = x \<+\> y@) is excluded.
usesOperatorInHead :: Text -> Text -> Bool
usesOperatorInHead name line =
    (" " <> name <> " ") `T.isInfixOf` (" " <> T.strip head_ <> " ")
  where
    head_ = fst (T.breakOn "=" line)


-- | Whether @name@ is an operator (its first character is not identifier-like).
isOperatorName :: Text -> Bool
isOperatorName = maybe False (not . isIdentChar . fst) . T.uncons


-- ── Type / class declarations ──────────────────────────────────────────────

-- | Slice the type-level declaration head introducing @name@. A type\/class
-- body is entirely indented, so no column-0 line below the head belongs to it.
sliceType :: Text -> [Text] -> Maybe Text
sliceType name ls =
    (\i -> expandAt (const False) i ls) <$> List.findIndex (introducesType name) ls


-- | Whether a column-0 line is a declaration head for the type-level @name@.
introducesType :: Text -> Text -> Bool
introducesType name line =
    isCol0 line
        && case T.words line of
            ("data" : "family" : n : _) -> tok n == name
            ("type" : "family" : n : _) -> tok n == name
            ("data" : n : _) -> tok n == name
            ("newtype" : n : _) -> tok n == name
            ("type" : n : _) -> tok n == name
            ("class" : ws) -> maybe False ((== name) . tok) (classHead ws)
            _ -> False
  where
    tok = T.takeWhile isIdentChar


-- | The class name from the words following the @class@ keyword: the first word
-- after the superclass context (the last @=>@), or the very first word when
-- there is no context. So @class Eq a => Ord a where@ yields @Ord@, not the
-- superclass @Eq@.
classHead :: [Text] -> Maybe Text
classHead ws =
    case break (== "=>") (reverse (takeWhile (/= "where") ws)) of
        (afterContext, _ : _) -> viaNonEmpty last afterContext
        (_, []) -> listToMaybe ws


-- | Slice the @data@ \/ @newtype@ declaration whose body defines @name@ as a
-- constructor, returning that whole declaration block. Used as a fallback for
-- uppercase queries that do not name a type head.
sliceConstructor :: Text -> [Text] -> Maybe Text
sliceConstructor name ls =
    List.find (mentionsConstructor name) (map (\i -> expandAt (const False) i ls) dataHeads)
  where
    dataHeads = List.findIndices isDataNewtypeHead ls


-- | Whether a column-0 line opens a @data@ or @newtype@ declaration.
isDataNewtypeHead :: Text -> Bool
isDataNewtypeHead line =
    isCol0 line
        && case T.words line of
            ("data" : _) -> True
            ("newtype" : _) -> True
            _ -> False


-- | Whether a declaration block defines @name@ as a constructor. Lexical and
-- approximate, but it looks only at /constructor positions/ — the leading token
-- of each @|@-separated alternative in an ADT, or the names before @::@ in a
-- GADT — so a mention of @name@ as a field type or inside a doc comment does not
-- count, and we do not return a @data@ block that merely references it.
mentionsConstructor :: Text -> Text -> Bool
mentionsConstructor name block = name `elem` constructorNames block


-- | The constructor names introduced by a @data@ \/ @newtype@ block, ignoring
-- doc comments. Handles both ADT syntax (@= A x | B y@) and GADT syntax
-- (@A, B :: …@ lines under a @where@ head).
constructorNames :: Text -> [Text]
constructorNames block =
    case filter (not . isCommentLine) (T.lines block) of
        [] -> []
        code@(headLine : rest)
            | "where" `elem` T.words headLine -> concatMap gadtCons rest
            | otherwise -> adtCons (T.unwords code)
  where
    -- ADT: names lead each alternative after the first @=@.
    adtCons decl =
        let rhs = T.drop 1 (T.dropWhile (/= '=') decl)
        in  mapMaybe (leadingCon . T.stripStart) (T.splitOn "|" rhs)
    -- GADT: @Con1, Con2 :: …@ — names before the @::@.
    gadtCons line
        | "::" `T.isInfixOf` line =
            mapMaybe (leadingCon . T.strip) (T.splitOn "," (fst (T.breakOn "::" line)))
        | otherwise = []
    leadingCon t = case leadingIdent t of
        "" -> Nothing
        ident -> Just ident


-- ── Block expansion ────────────────────────────────────────────────────────

-- | Expand the declaration anchored at line index @i@ into its full slice: the
-- contiguous doc-comment block above @i@, then the declaration from @i@ down to
-- (but not including) the next top-level declaration.
--
-- The block ends at the next top-level (column-0) declaration. Indented lines,
-- blank lines, and CPP directives all continue it — so a blank line inside a
-- @where@ clause or between guards does not truncate the slice — while
-- @sameDecl@ recognises the column-0 lines that also continue it (further
-- equations of a value binding, or an operator's infix body). Trailing blank
-- lines picked up before the boundary are trimmed off.
expandAt :: (Text -> Bool) -> Int -> [Text] -> Text
expandAt sameDecl i ls =
    let (before, rest) = splitAt i ls
        docBlock = docCommentAbove before
        body = case rest of
            [] -> []
            (hd : tl) -> hd : takeWhile continuesBody tl
    in  T.intercalate "\n" (docBlock <> dropTrailingBlanks body)
  where
    continuesBody line =
        isBlank line
            || not (isCol0 line)
            || isCppLine line
            || sameDecl line
    dropTrailingBlanks = reverse . dropWhile isBlank . reverse


-- | The contiguous doc-comment block immediately above a declaration, in source
-- order. Handles line comments (@--@) and multi-line block comments
-- (@{- … -}@), whose interior and closing lines are not themselves
-- comment-prefixed. Stops at the first non-comment (or blank) line.
docCommentAbove :: [Text] -> [Text]
docCommentAbove before = reverse (takeDoc (reverse before))
  where
    takeDoc [] = []
    takeDoc (l : ls)
        | isCommentLine l = l : takeDoc ls
        | closesBlockComment l =
            let (blockBody, ls') = consumeToBlockOpen ls
            in  (l : blockBody) <> takeDoc ls'
        | otherwise = []
    -- Consume upward (source-reversed) until the line that opens the block.
    consumeToBlockOpen [] = ([], [])
    consumeToBlockOpen (l : ls)
        | opensBlockComment l = ([l], ls)
        | otherwise = let (blockBody, ls') = consumeToBlockOpen ls in (l : blockBody, ls')
    closesBlockComment l = "-}" `T.isSuffixOf` T.stripEnd l && not (opensBlockComment l)
    opensBlockComment l = "{-" `T.isPrefixOf` T.stripStart l


-- ── Lexical helpers ────────────────────────────────────────────────────────

-- | A line that is empty or only whitespace.
isBlank :: Text -> Bool
isBlank = T.null . T.strip


-- | A line whose first non-blank content opens a comment (@--@ or @{-@). Used
-- to gather the doc block above a declaration.
isCommentLine :: Text -> Bool
isCommentLine line =
    let s = T.stripStart line
    in  "--" `T.isPrefixOf` s || "{-" `T.isPrefixOf` s


-- | A CPP directive line (@#if@, @#else@, @#endif@, …). These sit at column 0
-- but are transparent to declaration boundaries, so a slice spans them.
isCppLine :: Text -> Bool
isCppLine = T.isPrefixOf "#" . T.stripStart


-- | A line whose first character is in column 0 (not indented, not empty).
isCol0 :: Text -> Bool
isCol0 line = case T.uncons line of
    Just (c, _) -> not (isSpace c)
    Nothing -> False


-- | The leading identifier token of a line, or @""@ when it does not start with
-- one. E.g. @"foo x = 1"@ → @"foo"@, @"foo :: Int"@ → @"foo"@, @"-- doc"@ → @""@.
leadingIdent :: Text -> Text
leadingIdent = T.takeWhile isIdentChar


-- | Characters that may appear in a Haskell identifier.
isIdentChar :: Char -> Bool
isIdentChar c = isAlphaNum c || c == '_' || c == '\''