canvas-lang
Version: 
A simple ASCII graphics language for the terminal
295 lines (260 loc) • 7.64 kB
JavaScript
// Tokenizer
export const tokenizeInput = (input) => {
    const tokens = [];
    const tokenPatterns = [
      { type: "NUMBER", regex: /^\d+(\.\d+)?/ },
      { type: "STRING", regex: /^"([^"]*)"/ },
      { type: "ID", regex: /^[a-zA-Z][a-zA-Z0-9_]*/ },
      { type: "LPAREN", regex: /^\(/ },
      { type: "RPAREN", regex: /^\)/ },
      { type: "LBRACE", regex: /^\{/ },
      { type: "RBRACE", regex: /^\}/ },
      { type: "LBRACKET", regex: /^\[/ },
      { type: "RBRACKET", regex: /^\]/ },
      { type: "SEMICOLON", regex: /^;/ },
      { type: "COMMA", regex: /^,/ },
      { type: "ASSIGN", regex: /^=/ },
      { type: "WHITESPACE", regex: /^\s+/, ignore: true },
      { type: "COMMENT", regex: /^\/\/.*/, ignore: true },
      { type: "ARROW", regex: /^->/ }
    ];
  
    while (input.length > 0) {
      let matched = false;
      for (let { type, regex, ignore } of tokenPatterns) {
        const match = input.match(regex);
        if (match) {
          if (!ignore) tokens.push({ type, value: match[0] });
          input = input.slice(match[0].length);
          matched = true;
          break;
        }
      }
      if (!matched) throw new Error(`Unexpected token: ${input[0]}`);
    }
  
    return tokens;
  };
  
  // Parser
  export const parseTokens = (tokens) => {
    let currentTokenIndex = 0;
    
    const peek = () => tokens[currentTokenIndex] || { type: "EOF", value: "EOF" };
    const consume = () => tokens[currentTokenIndex++];
    const match = (type) => {
      if (peek().type === type) {
        return consume();
      }
      throw new Error(`Expected ${type}, got ${peek().type}`);
    };
    
    const parseCanvas = () => {
      const ast = { type: "canvas", commands: [] };
      
      match("ID"); // canvas
      match("LBRACE");
      
      while (peek().type !== "RBRACE" && peek().type !== "EOF") {
        ast.commands.push(parseCommand());
      }
      
      match("RBRACE");
      return ast;
    };
    
    const parseCommand = () => {
      const token = peek();
      
      if (token.type === "ID") {
        switch (token.value) {
          case "background":
            return parseBackground();
          case "circle":
            return parseCircle();
          case "rect":
            return parseRect();
          case "text":
            return parseText();
          case "line":
            return parseLine();
          case "animate":
            return parseAnimation();
          case "var":
            return parseVariable();
          case "rainbow":
            return parseRainbow();
          case "wait":
            return parseWait();
          case "frame":
            return parseFrame();
          default:
            throw new Error(`Unknown command: ${token.value}`);
        }
      }
      
      throw new Error(`Expected command, got ${token.type}`);
    };
    
    const parseBackground = () => {
      consume(); // background
      const color = match("STRING");
      match("SEMICOLON");
      return { type: "background", color: color.value };
    };
    
    const parseCircle = () => {
      consume(); // circle
      match("ID"); // at
      match("LPAREN");
      const x = match("NUMBER");
      match("COMMA");
      const y = match("NUMBER");
      match("RPAREN");
      match("ID"); // radius
      const radius = match("NUMBER");
      match("ID"); // fill
      const color = match("STRING");
      match("SEMICOLON");
      
      return {
        type: "circle",
        x: parseFloat(x.value),
        y: parseFloat(y.value),
        radius: parseFloat(radius.value),
        fill: color.value
      };
    };
    
    const parseRect = () => {
      consume(); // rect
      match("ID"); // at
      match("LPAREN");
      const x = match("NUMBER");
      match("COMMA");
      const y = match("NUMBER");
      match("RPAREN");
      match("ID"); // width
      const width = match("NUMBER");
      match("ID"); // height
      const height = match("NUMBER");
      match("ID"); // fill
      const color = match("STRING");
      match("SEMICOLON");
      
      return {
        type: "rect",
        x: parseFloat(x.value),
        y: parseFloat(y.value),
        width: parseFloat(width.value),
        height: parseFloat(height.value),
        fill: color.value
      };
    };
    
    const parseText = () => {
      consume(); // text
      const text = match("STRING");
      match("ID"); // at
      match("LPAREN");
      const x = match("NUMBER");
      match("COMMA");
      const y = match("NUMBER");
      match("RPAREN");
      match("ID"); // size
      const size = match("NUMBER");
      match("ID"); // color
      const color = match("STRING");
      match("SEMICOLON");
      
      return {
        type: "text",
        text: text.value.replace(/"/g, ''),
        x: parseFloat(x.value),
        y: parseFloat(y.value),
        size: parseInt(size.value),
        color: color.value
      };
    };
    
    const parseLine = () => {
      consume(); // line
      match("ID"); // from
      match("LPAREN");
      const x1 = match("NUMBER");
      match("COMMA");
      const y1 = match("NUMBER");
      match("RPAREN");
      match("ID"); // to
      match("LPAREN");
      const x2 = match("NUMBER");
      match("COMMA");
      const y2 = match("NUMBER");
      match("RPAREN");
      match("ID"); // color
      const color = match("STRING");
      match("SEMICOLON");
      
      return {
        type: "line",
        x1: parseFloat(x1.value),
        y1: parseFloat(y1.value),
        x2: parseFloat(x2.value),
        y2: parseFloat(y2.value),
        color: color.value
      };
    };
    
    const parseAnimation = () => {
      consume(); // animate
      match("LBRACE");
      
      const frames = [];
      while (peek().type !== "RBRACE" && peek().type !== "EOF") {
        const cmd = parseCommand();
        frames.push(cmd);
      }
      
      match("RBRACE");
      match("ID"); // for
      const duration = match("NUMBER");
      match("SEMICOLON");
      
      return {
        type: "animate",
        frames,
        duration: parseInt(duration.value)
      };
    };
    
    const parseVariable = () => {
      consume(); // var
      const name = match("ID");
      match("ASSIGN");
      const value = match("NUMBER");
      match("SEMICOLON");
      
      return {
        type: "variable",
        name: name.value,
        value: parseFloat(value.value)
      };
    };
    
    const parseRainbow = () => {
      consume(); // rainbow
      const text = match("STRING");
      match("ID"); // at
      match("LPAREN");
      const x = match("NUMBER");
      match("COMMA");
      const y = match("NUMBER");
      match("RPAREN");
      match("ID"); // duration
      const duration = match("NUMBER");
      match("SEMICOLON");
      
      return {
        type: "rainbow",
        text: text.value.replace(/"/g, ''),
        x: parseFloat(x.value),
        y: parseFloat(y.value),
        duration: parseInt(duration.value)
      };
    };
    
    const parseWait = () => {
      consume(); // wait
      const duration = match("NUMBER");
      match("SEMICOLON");
      
      return {
        type: "wait",
        duration: parseInt(duration.value)
      };
    };
    
    const parseFrame = () => {
      consume(); // frame
      match("LBRACE");
      
      const commands = [];
      while (peek().type !== "RBRACE" && peek().type !== "EOF") {
        commands.push(parseCommand());
      }
      
      match("RBRACE");
      
      return {
        type: "frame",
        commands
      };
    };
    
    return parseCanvas();
  };