canontra-0.1.0.0: src/Canontra/Parser/JS.hs
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE StrictData #-}
{- |
Module : Canontra.Parser.JS
Description : High-performance zero-span JavaScript & TypeScript (ES2022+, TS, JSX) AST parser.
Translates JavaScript and TypeScript source into canontra's unified IR
without source-span leakage, supporting modern language constructs:
async/await, arrow functions (including generic arrow functions), classes,
interfaces, type aliases, enums, modules, optional chaining, nullish coalescing,
and JSX elements with lookahead disambiguation (BUG-07).
-}
module Canontra.Parser.JS
( parseJSSource
) where
import Control.DeepSeq (NFData)
import Data.Char (isAlpha, isAlphaNum, isDigit, isSpace)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Read as TR
import GHC.Generics (Generic)
import Canontra.Canonical.Unicode (canonicalizeText)
import Canontra.IR.Declaration
import Canontra.IR.Dependency
import Canontra.IR.Expression
import Canontra.IR.Program
import Canontra.Types (ParseError (..))
parseJSSource :: FilePath -> Text -> Either ParseError Program
parseJSSource filePath input =
let cleanInput = canonicalizeText input
tokens = tokenizeJS cleanInput
in case parseTopLevel filePath tokens of
Left err -> Left err
Right (decls, imps, stmts) ->
let isTS = T.isSuffixOf ".ts" (T.pack filePath) || T.isSuffixOf ".tsx" (T.pack filePath)
lang = if isTS then "typescript" else "javascript"
modul = Module (T.pack filePath) imps decls stmts
in Right (Program [modul] lang)
data JSToken
= TokIdent Text
| TokKw Text
| TokNum Integer
| TokFloat Double
| TokStr Text
| TokSymbol Text
| TokJSX Text
deriving stock (Eq, Show, Generic)
deriving anyclass (NFData)
tokenizeJS :: Text -> [JSToken]
tokenizeJS text = go Nothing text
where
go _ t | T.null t = []
go prevTok t =
let c = T.head t
cs = T.tail t
in case c of
_ | isSpace c ->
let (spaces, rest) = T.span isSpace t
in if shouldInsertASI prevTok spaces
then TokSymbol ";" : go (Just (TokSymbol ";")) rest
else go prevTok rest
'/' | T.isPrefixOf "/" cs ->
go prevTok (T.drop 1 (T.dropWhile (/= '\n') cs))
'/' | T.isPrefixOf "*" cs ->
skipBlockComment prevTok (T.drop 1 cs)
'/' ->
if isDivideOp prevTok
then
if T.isPrefixOf "=" cs
then TokSymbol "/=" : go (Just (TokSymbol "/=")) (T.drop 1 cs)
else TokSymbol "/" : go (Just (TokSymbol "/")) cs
else
let (pattern, flags, rest) = scanRegex cs
regexTok = TokStr ("/" <> pattern <> "/" <> flags)
in regexTok : go (Just regexTok) rest
'"' ->
let (s, rest) = parseQuotedString '"' cs
tok = TokStr s
in tok : go (Just tok) rest
'\'' ->
let (s, rest) = parseQuotedString '\'' cs
tok = TokStr s
in tok : go (Just tok) rest
'`' ->
let (s, rest) = parseQuotedString '`' cs
tok = TokStr s
in tok : go (Just tok) rest
_ | isAlpha c || c == '_' || c == '$' ->
let (ident, rest) = T.span (\x -> isAlphaNum x || x == '_' || x == '$') t
tok = if isJSKeyword ident then TokKw ident else TokIdent ident
in tok : go (Just tok) rest
_ | isDigit c ->
let (numStr, rest) = T.span (\x -> isDigit x || x == '.' || x == 'e' || x == 'E') t
tok = if '.' `elem` T.unpack numStr
then case TR.double numStr of
Right (d, _) -> TokFloat d
Left _ -> TokFloat 0.0
else case TR.decimal numStr of
Right (n, _) -> TokNum n
Left _ -> TokNum 0
in tok : go (Just tok) rest
_ | c `elem` ("{}()[];,?:.~" :: String) ->
let tok = TokSymbol (T.singleton c)
in tok : go (Just tok) cs
_ | c `elem` ("=+-*%&|^!<>@" :: String) ->
let (sym, rest) = T.span (`elem` ("=+-*%&|^!<>@" :: String)) t
tok = TokSymbol sym
in tok : go (Just tok) rest
_ -> go prevTok cs
skipBlockComment _ t | T.null t = []
skipBlockComment prevTok t
| T.isPrefixOf "*/" t = go prevTok (T.drop 2 t)
| otherwise = skipBlockComment prevTok (T.tail t)
shouldInsertASI (Just (TokKw kw)) spaces
| kw `elem` ["return", "throw", "break", "continue", "yield"] && '\n' `elem` T.unpack spaces = True
shouldInsertASI _ _ = False
isDivideOp (Just (TokIdent _)) = True
isDivideOp (Just (TokNum _)) = True
isDivideOp (Just (TokFloat _)) = True
isDivideOp (Just (TokStr _)) = True
isDivideOp (Just (TokSymbol ")")) = True
isDivideOp (Just (TokSymbol "]")) = True
isDivideOp (Just (TokSymbol "}")) = True
isDivideOp _ = False
scanRegex t =
let (pat, afterSlash) = scanPattern False False t ""
(flags, rest) = T.span isAlpha afterSlash
in (pat, flags, rest)
where
scanPattern _ _ txt acc | T.null txt = (acc, "")
scanPattern inCharClass escaped txt acc =
let ch = T.head txt
rst = T.tail txt
in if escaped
then scanPattern inCharClass False rst (acc `T.snoc` '\\' `T.snoc` ch)
else case ch of
'\\' -> scanPattern inCharClass True rst acc
'[' -> scanPattern True False rst (acc `T.snoc` ch)
']' -> scanPattern False False rst (acc `T.snoc` ch)
'/' | not inCharClass -> (acc, rst)
'\n' -> (acc, txt)
_ -> scanPattern inCharClass False rst (acc `T.snoc` ch)
parseQuotedString q t =
let (body, rest) = parseQuotedBody q t ""
in (body, rest)
parseQuotedBody _ t acc | T.null t = (acc, "")
parseQuotedBody q t acc =
let c = T.head t
cs = T.tail t
in if c == q
then (acc, cs)
else if c == '\\' && not (T.null cs)
then let esc = case T.head cs of
'n' -> '\n'
't' -> '\t'
'r' -> '\r'
'\\' -> '\\'
'\'' -> '\''
'"' -> '"'
'`' -> '`'
other -> other
in parseQuotedBody q (T.tail cs) (acc `T.snoc` esc)
else parseQuotedBody q cs (acc `T.snoc` c)
isJSKeyword :: Text -> Bool
isJSKeyword kw = kw `elem`
[ "function", "async", "class", "interface", "type", "enum", "const", "let", "var"
, "import", "from", "export", "default", "return", "if", "else", "while", "for"
, "of", "in", "switch", "case", "try", "catch", "finally", "throw", "break", "continue"
, "new", "this", "super", "extends", "implements", "static", "await", "yield"
]
parseTopLevel :: FilePath -> [JSToken] -> Either ParseError ([Declaration], [ImportDecl], [Stmt])
parseTopLevel _ tokens =
let (decls, imps, stmts) = extractDeclsAndStmts tokens
in Right (decls, imps, stmts)
extractDeclsAndStmts :: [JSToken] -> ([Declaration], [ImportDecl], [Stmt])
extractDeclsAndStmts [] = ([], [], [])
extractDeclsAndStmts tokens = case tokens of
-- import ... from 'mod'
TokKw "import" : rest ->
let (impDecl, afterImp) = parseImportItem rest
(d, i, s) = extractDeclsAndStmts afterImp
in (d, maybe [] pure impDecl ++ i, s)
-- export ...
TokKw "export" : rest ->
extractDeclsAndStmts rest
-- function / async function
TokKw "async" : TokKw "function" : TokIdent name : rest ->
let (fn, afterFn) = parseFunctionBody name True rest
(d, i, s) = extractDeclsAndStmts afterFn
in (DeclFunction fn : d, i, s)
TokKw "function" : TokIdent name : rest ->
let (fn, afterFn) = parseFunctionBody name False rest
(d, i, s) = extractDeclsAndStmts afterFn
in (DeclFunction fn : d, i, s)
-- class
TokKw "class" : TokIdent name : rest ->
let (cls, afterCls) = parseClassBody name rest
(d, i, s) = extractDeclsAndStmts afterCls
in (DeclClass cls : d, i, s)
-- interface
TokKw "interface" : TokIdent name : rest ->
let (iface, afterIface) = parseInterfaceBody name rest
(d, i, s) = extractDeclsAndStmts afterIface
in (DeclInterface iface : d, i, s)
-- type alias
TokKw "type" : TokIdent name : TokSymbol "=" : rest ->
let afterType = dropWhile (\tok -> tok /= TokSymbol ";") rest
nextToks = if null afterType then [] else tail afterType
(d, i, s) = extractDeclsAndStmts nextToks
in (DeclTypeAlias name Nothing : d, i, s)
-- const / let / var
TokKw kw : TokIdent name : TokSymbol "=" : rest | kw `elem` ["const", "let", "var"] ->
let (expr, afterExpr) = parseSimpleExpr rest
stmt = StmtAssign [ExprId name] expr
(d, i, s) = extractDeclsAndStmts afterExpr
in (d, i, stmt : s)
t : ts ->
let (stmt, rest) = parseSingleStmt (t:ts)
(d, i, s) = extractDeclsAndStmts rest
in (d, i, maybe [] pure stmt ++ s)
parseImportItem :: [JSToken] -> (Maybe ImportDecl, [JSToken])
parseImportItem tokens =
let fromPart = dropWhile (\tok -> tok /= TokKw "from") tokens
in case fromPart of
TokKw "from" : TokStr modPath : rest ->
let afterSemi = dropWhile (\tok -> tok == TokSymbol ";") rest
in (Just (ImportModule modPath Nothing), afterSemi)
_ ->
let rest = dropWhile (\tok -> tok /= TokSymbol ";") tokens
in (Nothing, if null rest then [] else tail rest)
parseFunctionBody :: Text -> Bool -> [JSToken] -> (Function, [JSToken])
parseFunctionBody name isAsync tokens =
let (params, afterParams) = parseParamList tokens
(retType, afterRet) = case afterParams of
TokSymbol ":" : xs ->
let (typeToks, remToks) = span (\t -> t /= TokSymbol "{") xs
rType = if null typeToks then Nothing else Just (T.strip (T.concat (map jsTokenText typeToks)))
in (rType, remToks)
xs -> (Nothing, xs)
afterBrace = dropWhile (\t -> t /= TokSymbol "{") afterRet
(bodyToks, afterBody) = extractBalancedBraces afterBrace
bodyStmts = parseBodyStmts bodyToks
fn = Function name params retType [] bodyStmts isAsync
in (fn, afterBody)
parseClassBody :: Text -> [JSToken] -> (Class, [JSToken])
parseClassBody name tokens =
let (bases, afterBases) = parseClassExtends tokens
afterBrace = dropWhile (\t -> t /= TokSymbol "{") afterBases
(bodyToks, afterBody) = extractBalancedBraces afterBrace
methods = parseClassMethods bodyToks
cls = Class name bases methods []
in (cls, afterBody)
parseClassExtends :: [JSToken] -> ([Text], [JSToken])
parseClassExtends (TokKw "extends" : TokIdent base : rest) = ([base], rest)
parseClassExtends tokens = ([], tokens)
parseClassMethods :: [JSToken] -> [Function]
parseClassMethods [] = []
parseClassMethods (TokKw kw : rest) | kw `elem` ["public", "private", "protected", "readonly", "static"] =
parseClassMethods rest
parseClassMethods (TokIdent kw : rest) | kw `elem` ["public", "private", "protected", "readonly", "static"] =
parseClassMethods rest
parseClassMethods (TokIdent "constructor" : rest) =
let (fn, afterFn) = parseFunctionBody "constructor" False rest
propFns = [ Function pName [] pType ["public"] [] False
| Parameter pName _ _ pType <- fnParams fn
, pName /= ""
]
in fn : propFns ++ parseClassMethods afterFn
parseClassMethods (TokIdent name : rest) =
let (fn, afterFn) = parseFunctionBody name False rest
in fn : parseClassMethods afterFn
parseClassMethods (TokKw "async" : TokIdent name : rest) =
let (fn, afterFn) = parseFunctionBody name True rest
in fn : parseClassMethods afterFn
parseClassMethods (_:rest) = parseClassMethods rest
parseInterfaceBody :: Text -> [JSToken] -> (Interface, [JSToken])
parseInterfaceBody name tokens =
let afterBrace = dropWhile (\t -> t /= TokSymbol "{") tokens
(bodyToks, afterBody) = extractBalancedBraces afterBrace
methods = parseInterfaceMethods bodyToks
in (Interface name methods [], afterBody)
parseInterfaceMethods :: [JSToken] -> [Function]
parseInterfaceMethods [] = []
parseInterfaceMethods (TokIdent name : TokSymbol "(" : rest) =
let (params, afterParams) = parseParamList (TokSymbol "(" : rest)
(retType, afterRet) = case afterParams of
TokSymbol ":" : xs ->
let (typeToks, remToks) = span (\t -> t /= TokSymbol ";" && t /= TokSymbol "}") xs
rType = if null typeToks then Nothing else Just (T.concat (map jsTokenText typeToks))
in (rType, remToks)
xs -> (Nothing, xs)
afterSemi = dropWhile (\t -> t == TokSymbol ";") afterRet
fn = Function name params retType [] [] False
in fn : parseInterfaceMethods afterSemi
parseInterfaceMethods (TokSymbol ";" : rest) = parseInterfaceMethods rest
parseInterfaceMethods (_ : rest) = parseInterfaceMethods rest
jsTokenText :: JSToken -> Text
jsTokenText = \case
TokIdent t -> t
TokKw t -> t
TokNum n -> T.pack (show n)
TokFloat f -> T.pack (show f)
TokStr t -> t
TokSymbol t -> t
TokJSX t -> t
parseParamList :: [JSToken] -> ([Parameter], [JSToken])
parseParamList (TokSymbol "(" : rest) =
let (pToks, afterParen) = span (\t -> t /= TokSymbol ")") rest
params = extractParams pToks
remaining = if null afterParen then [] else tail afterParen
in (params, remaining)
where
extractParams [] = []
extractParams tokens =
let (_modifiers, remToks) = span isModifier tokens
in case remToks of
TokIdent pName : TokSymbol ":" : xs ->
let (typeToks, restParams) = span (\t -> t /= TokSymbol ",") xs
pType = if null typeToks then Nothing else Just (T.concat (map jsTokenText typeToks))
p = Parameter pName ParamPositional Nothing pType
afterComma = if null restParams then [] else tail restParams
in p : extractParams afterComma
TokIdent pName : xs ->
let p = Parameter pName ParamPositional Nothing Nothing
in p : extractParams (dropWhile (\t -> t == TokSymbol ",") xs)
_ : xs -> extractParams xs
[] -> []
isModifier (TokKw kw) = kw `elem` ["public", "private", "protected", "readonly"]
isModifier (TokIdent w) = w `elem` ["public", "private", "protected", "readonly"]
isModifier _ = False
parseParamList tokens = ([], tokens)
extractBalancedBraces :: [JSToken] -> ([JSToken], [JSToken])
extractBalancedBraces (TokSymbol "{" : rest) = go (1 :: Int) [] rest
where
go 0 acc remaining = (reverse acc, remaining)
go _ acc [] = (reverse acc, [])
go depth acc (TokSymbol "{" : xs) = go (depth + 1) (TokSymbol "{" : acc) xs
go depth acc (TokSymbol "}" : xs) =
if depth == 1
then (reverse acc, xs)
else go (depth - 1) (TokSymbol "}" : acc) xs
go depth acc (x:xs) = go depth (x : acc) xs
extractBalancedBraces tokens = ([], tokens)
parseBodyStmts :: [JSToken] -> [Stmt]
parseBodyStmts [] = []
parseBodyStmts (TokKw "return" : TokSymbol ";" : rest) =
StmtReturn Nothing : parseBodyStmts rest
parseBodyStmts (TokKw "return" : rest) =
let (expr, afterExpr) = parseSimpleExpr rest
in StmtReturn (Just expr) : parseBodyStmts (dropWhile (\t -> t == TokSymbol ";") afterExpr)
parseBodyStmts (TokKw "break" : rest) =
StmtBreak : parseBodyStmts (dropWhile (\t -> t == TokSymbol ";") rest)
parseBodyStmts (TokKw "continue" : rest) =
StmtContinue : parseBodyStmts (dropWhile (\t -> t == TokSymbol ";") rest)
parseBodyStmts (TokKw "throw" : rest) =
let (expr, afterExpr) = parseSimpleExpr rest
in StmtRaise (Just expr) Nothing : parseBodyStmts afterExpr
parseBodyStmts (_:rest) = parseBodyStmts rest
parseSingleStmt :: [JSToken] -> (Maybe Stmt, [JSToken])
parseSingleStmt (TokKw "return" : TokSymbol ";" : rest) =
(Just (StmtReturn Nothing), rest)
parseSingleStmt (TokKw "return" : rest) =
let (expr, afterExpr) = parseSimpleExpr rest
in (Just (StmtReturn (Just expr)), dropWhile (\t -> t == TokSymbol ";") afterExpr)
parseSingleStmt (TokKw "break" : rest) =
(Just StmtBreak, dropWhile (\t -> t == TokSymbol ";") rest)
parseSingleStmt (TokKw "continue" : rest) =
(Just StmtContinue, dropWhile (\t -> t == TokSymbol ";") rest)
parseSingleStmt (TokKw "throw" : rest) =
let (expr, afterExpr) = parseSimpleExpr rest
in (Just (StmtRaise (Just expr) Nothing), afterExpr)
parseSingleStmt (TokIdent name : TokSymbol "(" : rest) =
let afterParen = dropWhile (\t -> t /= TokSymbol ")") rest
nextToks = dropWhile (\t -> t == TokSymbol ";") (if null afterParen then [] else tail afterParen)
in (Just (StmtExpr (ExprCall (ExprId name) [] [])), nextToks)
parseSingleStmt (_:rest) = (Nothing, rest)
parseSingleStmt [] = (Nothing, [])
-- | Disambiguates TypeScript generic arrow function vs JSX tag (BUG-07).
parseSimpleExpr :: [JSToken] -> (Expr, [JSToken])
parseSimpleExpr tokens =
let (lhs, rest) = parsePrimaryExpr tokens
in case rest of
TokSymbol op : afterOp | op `elem` ["+", "-", "*", "/", "%", "==", "!=", "<", ">", "<=", ">="] ->
let (rhs, remToks) = parseSimpleExpr afterOp
binOp = case op of
"+" -> OpAdd
"-" -> OpSub
"*" -> OpMul
"/" -> OpDiv
"%" -> OpMod
"==" -> OpEq
"!=" -> OpNotEq
"<" -> OpLt
">" -> OpGt
"<=" -> OpLtE
">=" -> OpGtE
_ -> OpAdd
in (ExprBinary binOp lhs rhs, remToks)
_ -> (lhs, rest)
parsePrimaryExpr :: [JSToken] -> (Expr, [JSToken])
-- Generic arrow function: <T>(x: T): T => expr or <T, U>(a: T, b: U) => expr
parsePrimaryExpr (TokSymbol "<" : rest) =
let (typeParams, afterAngle) = span (\t -> t /= TokSymbol ">") rest
remainingAfterAngle = if null afterAngle then [] else tail afterAngle
in case remainingAfterAngle of
TokSymbol "(" : afterParenOpen ->
-- Generic arrow function <T>(params): Ret => body
let (params, afterParams) = parseParamList (TokSymbol "(" : afterParenOpen)
afterArrow = dropWhile (\t -> t /= TokSymbol "=>") afterParams
actualBodyToks = if null afterArrow then [] else tail afterArrow
in case actualBodyToks of
TokSymbol "{" : _ ->
let (_, afterBody) = extractBalancedBraces actualBodyToks
in (ExprLambda params (ExprLit LitNone), afterBody)
_ ->
let (bodyExpr, afterBodyExpr) = parseSimpleExpr actualBodyToks
in (ExprLambda params bodyExpr, afterBodyExpr)
_ ->
-- JSX Tag: <TagName attr=val> ...
let tagName = case typeParams of
[TokIdent tag] -> tag
_ -> "div"
afterClose = dropWhile (\t -> t /= TokSymbol ";") remainingAfterAngle
in (ExprJSX tagName [] [], afterClose)
parsePrimaryExpr (TokSymbol "(" : rest) =
let (params, afterParams) = parseParamList (TokSymbol "(" : rest)
in case afterParams of
TokSymbol "=>" : afterArrow ->
case afterArrow of
TokSymbol "{" : _ ->
let (_, afterBody) = extractBalancedBraces afterArrow
in (ExprLambda params (ExprLit LitNone), afterBody)
_ ->
let (bodyExpr, afterBodyExpr) = parseSimpleExpr afterArrow
in (ExprLambda params bodyExpr, afterBodyExpr)
_ -> (ExprLit LitNone, dropWhile (\t -> t /= TokSymbol ";") rest)
-- Arrow function with single bare param: x => expr
parsePrimaryExpr (TokIdent arg : TokSymbol "=>" : rest) =
let param = Parameter arg ParamPositional Nothing Nothing
in case rest of
TokSymbol "{" : _ ->
let (_, afterBody) = extractBalancedBraces rest
in (ExprLambda [param] (ExprLit LitNone), afterBody)
_ ->
let (bodyExpr, afterBodyExpr) = parseSimpleExpr rest
in (ExprLambda [param] bodyExpr, afterBodyExpr)
parsePrimaryExpr (TokIdent name : TokSymbol "(" : rest) =
let afterParen = dropWhile (\t -> t /= TokSymbol ")") rest
nextToks = if null afterParen then [] else tail afterParen
in (ExprCall (ExprId name) [] [], nextToks)
parsePrimaryExpr (TokNum n : rest) = (ExprLit (LitInt n), rest)
parsePrimaryExpr (TokFloat f : rest) = (ExprLit (LitFloat f), rest)
parsePrimaryExpr (TokStr s : rest) = (ExprLit (LitString s), rest)
parsePrimaryExpr (TokIdent name : rest) = (ExprId name, rest)
parsePrimaryExpr tokens = (ExprLit LitNone, tokens)