Source code for runly.parser

"""
Parser for justfile format.
"""

import os
import re
import yaml
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Set

from .commands import Command, CommandSet
from .exceptions import JustfileParseError, JustfileSyntaxError
from .utils import expand_variables


[docs] class JustfileParser: """Parser for justfile and YAML configuration files."""
[docs] def __init__(self, filepath: str): """Initialize the parser with a justfile path.""" self.filepath = filepath self.dirname = os.path.dirname(os.path.abspath(filepath)) self._variables: Dict[str, str] = {}
[docs] def parse(self) -> CommandSet: """Parse the justfile and return a CommandSet.""" # Determine file format based on extension _, ext = os.path.splitext(self.filepath) if ext.lower() in ('.yml', '.yaml'): return self._parse_yaml() else: return self._parse_justfile()
def _parse_yaml(self) -> CommandSet: """Parse a YAML-based justfile.""" with open(self.filepath, 'r') as f: config = yaml.safe_load(f) if not isinstance(config, dict): raise ValueError("Invalid YAML file: root must be a dictionary") # Extract variables variables = config.get('variables', {}) self._variables = {k: str(v) for k, v in variables.items()} # Extract commands commands_data = config.get('commands', {}) if not isinstance(commands_data, dict): raise ValueError("Invalid YAML file: 'commands' must be a dictionary") commands = [] default_command = config.get('default') for name, data in commands_data.items(): if isinstance(data, str): # Simple string command commands.append(Command( name=name, script=data, args=[], description="", dependencies=set() )) elif isinstance(data, dict): # Detailed command object script = data.get('script', '') if isinstance(script, list): script = '\n'.join(script) args = data.get('args', []) description = data.get('description', '') dependencies = set(data.get('dependencies', [])) commands.append(Command( name=name, script=script, args=args, description=description, dependencies=dependencies )) else: raise ValueError(f"Invalid command format for '{name}'") return CommandSet(commands=commands, default_command=default_command, variables=self._variables) def _parse_justfile(self) -> CommandSet: """Parse a traditional justfile.""" with open(self.filepath, 'r') as f: content = f.read() # Split content into lines and remove comments lines = [] for line in content.split('\n'): if '#' in line: line = line[:line.index('#')] lines.append(line) # Parse variables self._variables = self._extract_variables(lines) # Parse commands commands, default_command = self._extract_commands(lines) return CommandSet(commands=commands, default_command=default_command, variables=self._variables) def _extract_variables(self, lines: List[str]) -> Dict[str, str]: """Extract variable definitions.""" variables = {} var_pattern = re.compile(r'^([A-Za-z0-9_-]+)\s*:=\s*(.+)$') for line in lines: line = line.strip() match = var_pattern.match(line) if match: name, value = match.groups() # Remove quotes from values if present if value.startswith('"') and value.endswith('"'): value = value[1:-1] elif value.startswith("'") and value.endswith("'"): value = value[1:-1] # Expand variables in the value value = expand_variables(value, variables) variables[name] = value return variables def _extract_commands(self, lines: List[str]) -> Tuple[List[Command], Optional[str]]: """Extract command definitions.""" commands = [] current_command = None current_description = [] current_script = [] current_dependencies = set() default_command = None # Pattern to match command definitions with dependencies (e.g., "deploy env: build" or "test *:") # but not variable assignments (e.g., "project := value") cmd_pattern = re.compile(r'^([A-Za-z0-9_-]+)(\s+([^:]*?))?\s*(\*)?\s*:\s*(.*)$') for line_num, line in enumerate(lines): stripped = line.strip() # Skip variable assignments if re.match(r'^[A-Za-z0-9_-]+\s*:=', stripped): continue # Check for command definition match = cmd_pattern.match(stripped) if match: # Save previous command if exists if current_command: commands.append(Command( name=current_command[0], script='\n'.join(current_script), args=current_command[1], description='\n'.join(current_description).strip(), dependencies=current_dependencies )) # Start new command cmd_name = match.group(1) args_str = match.group(3) or "" is_default = bool(match.group(4)) # The * marker deps_str = match.group(5) or "" # Parse arguments - in justfile format, args are space-separated args = [arg.strip() for arg in args_str.split() if arg.strip()] # Parse dependencies from the command line dependencies = set() if deps_str.strip(): dependencies = {dep.strip() for dep in deps_str.split() if dep.strip()} # Set default command if marked with * if is_default and default_command is None: default_command = cmd_name current_command = (cmd_name, args) current_description = [] current_script = [] current_dependencies = dependencies # No script part on the command line in this format elif line.startswith('@') and current_command is None: # This is a command description before the actual command current_description.append(line[1:].strip()) elif current_command is not None: # This line belongs to the current command's script # Check for indentation if line and (line.startswith(' ') or line.startswith('\t')): current_script.append(line.strip()) elif not line.strip(): # Empty line within command script current_script.append('') else: # No indentation means end of command commands.append(Command( name=current_command[0], script='\n'.join(current_script), args=current_command[1], description='\n'.join(current_description).strip(), dependencies=current_dependencies )) current_command = None current_description = [] current_script = [] current_dependencies = set() # Re-process this line lines.insert(line_num, line) # Add the last command if exists if current_command: commands.append(Command( name=current_command[0], script='\n'.join(current_script), args=current_command[1], description='\n'.join(current_description).strip(), dependencies=current_dependencies )) return commands, default_command def _extract_dependencies(self, script: str) -> Set[str]: """Extract command dependencies from script.""" dependencies = set() # Look for patterns like "just command" or "just command arg1 arg2" pattern = re.compile(r'(?:^|\n|\s)just\s+([A-Za-z0-9_-]+)') for match in pattern.finditer(script): dependencies.add(match.group(1)) return dependencies