#!/usr/bin/env python3 """ lime-compose -- Compose modular .lime grammar files into a single parser file. Modular .lime files use directive-based metadata (%module_name, %module_version, %require, %export, %import) embedded directly in the grammar. This tool extracts metadata, resolves inter-module dependencies via topological sort, and concatenates the results into a single .lime file suitable for the Lime parser generator. Usage: lime-compose [options] ... lime-compose --help Options: -o FILE, --output FILE Write composed grammar to FILE (default: stdout) -v, --verbose Print module resolution order to stderr -n, --dry-run Validate and show resolution order without output --no-validate Skip dependency validation --list-modules List modules found in input files and exit -h, --help Show this help message and exit """ from __future__ import print_function import argparse import os import re import sys # --------------------------------------------------------------------------- # Data structures # --------------------------------------------------------------------------- class Module(object): """A single grammar module extracted from a .lime file.""" def __init__(self, name, version, description, dependencies, exports, imports, content, source_file): self.name = name self.version = version self.description = description self.dependencies = dependencies # list of (name, constraint) tuples self.exports = exports # list of symbol names self.imports = imports # list of (symbol, from_module) tuples self.content = content # full file content self.source_file = source_file # --------------------------------------------------------------------------- # Parsing # --------------------------------------------------------------------------- def extract_directive(lines, directive_name): """Extract the first occurrence of a %directive from lines. Returns the value (without quotes) or None if not found. """ pattern = r'^\s*%' + re.escape(directive_name) + r'\s+(.+)$' for line in lines: m = re.match(pattern, line) if m: value = m.group(1).strip() # Remove trailing period if present if value.endswith('.'): value = value[:-1].strip() # Remove quotes if (value.startswith('"') and value.endswith('"')) or \ (value.startswith("'") and value.endswith("'")): value = value[1:-1] return value return None def extract_multi_directive(lines, directive_name): """Extract all occurrences of a multi-value %directive. Returns a list of tuples. For %require, returns [(name, constraint), ...] For %export, returns [symbol, ...]. For %import, returns [(symbol, from_module), ...]. """ results = [] pattern = r'^\s*%' + re.escape(directive_name) + r'\s+(.+)$' for line in lines: m = re.match(pattern, line) if m: rest = m.group(1).strip() # Remove trailing period if rest.endswith('.'): rest = rest[:-1].strip() if directive_name == 'require': # Parse: module_name [constraint] parts = rest.split(None, 1) module_name = parts[0] constraint = parts[1].strip('"\'') if len(parts) > 1 else '' results.append((module_name, constraint)) elif directive_name == 'export': # Parse: symbol1 symbol2 ... symbols = rest.split() results.extend(symbols) elif directive_name == 'import': # Parse: symbol1 symbol2 ... from module_name if ' from ' in rest: parts = rest.split(' from ', 1) symbols = parts[0].strip().split() module_name = parts[1].strip() for sym in symbols: results.append((sym, module_name)) return results def strip_module_directives(content): """Remove all module directives from content, keeping everything else.""" lines = content.split('\n') filtered = [] module_directive_pattern = re.compile( r'^\s*%(module_name|module_version|module_description|require|export|import)\b') for line in lines: if not module_directive_pattern.match(line): filtered.append(line) return '\n'.join(filtered) def parse_module_file(filepath): """Parse a modular .lime file and return a Module. The file should contain %module_name and %module_version directives. Other directives (%require, %export, %import) are optional. """ if not os.path.isfile(filepath): raise FileNotFoundError("File not found: %s" % filepath) with open(filepath, 'r') as f: content = f.read() lines = content.splitlines() # Extract metadata name = extract_directive(lines, 'module_name') version = extract_directive(lines, 'module_version') description = extract_directive(lines, 'module_description') or '' # If no module_name, treat as non-modular grammar if not name: raise ValueError( "%s: no %%module_name directive found. " "Modular grammars must have %%module_name." % filepath) if not version: raise ValueError( "%s: no %%module_version directive found. " "%%module_name requires %%module_version." % filepath) # Validate name is ASCII identifier if not re.match(r'^[A-Za-z_][A-Za-z0-9_.-]*$', name): raise ValueError( "%s: module name '%s' contains invalid characters. " "Use ASCII letters, digits, underscores, dots, and hyphens." % (filepath, name)) # Extract dependencies, exports, imports dependencies = extract_multi_directive(lines, 'require') exports = extract_multi_directive(lines, 'export') imports = extract_multi_directive(lines, 'import') return Module( name=name, version=version, description=description, dependencies=dependencies, exports=exports, imports=imports, content=content, source_file=filepath, ) # --------------------------------------------------------------------------- # Dependency resolution # --------------------------------------------------------------------------- def validate_dependencies(modules): """Check that all dependencies are satisfied by available modules. Returns a list of error strings (empty if valid). """ # Build the set of all module names available = {mod.name for mod in modules} errors = [] for mod in modules: for dep_name, constraint in mod.dependencies: if dep_name not in available: errors.append( "Module '%s' (%s) requires '%s', " "which is not available in the input modules." % (mod.name, mod.source_file, dep_name)) # Check for duplicate module names seen_names = {} for mod in modules: if mod.name in seen_names: errors.append( "Duplicate module name '%s': found in both '%s' and '%s'." % (mod.name, seen_names[mod.name], mod.source_file)) seen_names[mod.name] = mod.source_file # Check for duplicate exports (symbols exported by multiple modules) seen_exports = {} for mod in modules: for symbol in mod.exports: if symbol in seen_exports and seen_exports[symbol] != mod.name: errors.append( "Symbol '%s' is exported by both '%s' and '%s'." % (symbol, seen_exports[symbol], mod.name)) seen_exports[symbol] = mod.name # Check that imports reference exported symbols for mod in modules: for symbol, from_module in mod.imports: # Find the module that should export this symbol found = False for other in modules: if other.name == from_module: if symbol in other.exports: found = True break if not found: errors.append( "Module '%s' imports '%s' from '%s', " "but '%s' does not export it." % (mod.name, symbol, from_module, from_module)) return errors def topological_sort(modules): """Sort modules so that dependencies come before dependents. Uses Kahn's algorithm. Raises ValueError on cyclic dependencies. Returns a new list of modules in dependency order. """ # Map module name -> module by_name = {mod.name: mod for mod in modules} # Build adjacency: mod.name -> set of module names it depends on deps = {} for mod in modules: deps[mod.name] = set() for dep_name, constraint in mod.dependencies: if dep_name in by_name and dep_name != mod.name: deps[mod.name].add(dep_name) # Reverse adjacency: module name -> set of modules that depend on it rdeps = {mod.name: set() for mod in modules} for name, dep_set in deps.items(): for d in dep_set: rdeps[d].add(name) # Kahn's algorithm in_degree = {name: len(dep_set) for name, dep_set in deps.items()} queue = sorted([name for name, deg in in_degree.items() if deg == 0]) result = [] while queue: # Take the alphabetically first to ensure deterministic ordering current = queue.pop(0) result.append(current) for dependent in sorted(rdeps[current]): in_degree[dependent] -= 1 if in_degree[dependent] == 0: queue.append(dependent) queue.sort() if len(result) != len(modules): remaining = set(by_name.keys()) - set(result) raise ValueError( "Cyclic dependency detected among modules: %s" % ', '.join(sorted(remaining))) return [by_name[name] for name in result] # --------------------------------------------------------------------------- # Composition # --------------------------------------------------------------------------- def compose(modules, include_provenance=True): """Compose sorted modules into a single .lime grammar string.""" parts = [] parts.append("/*") parts.append(" * Composed grammar generated by lime-compose") parts.append(" *") parts.append(" * Modules (%d):" % len(modules)) for mod in modules: parts.append(" * - %s %s" % (mod.name, mod.version)) if mod.description: parts.append(" * %s" % mod.description) parts.append(" */") parts.append("") for mod in modules: if include_provenance: parts.append( "/* " + "=" * 66 + " */") parts.append( "/* Module: %-55s */" % mod.name) if mod.version != '0.0.0': parts.append( "/* Version: %-54s */" % mod.version) parts.append( "/* Source: %-55s */" % os.path.basename(mod.source_file)) parts.append( "/* " + "=" * 66 + " */") parts.append("") # Strip module directives from content clean_content = strip_module_directives(mod.content) parts.append(clean_content.rstrip()) parts.append("") return '\n'.join(parts) + '\n' # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def build_parser(): parser = argparse.ArgumentParser( prog='lime-compose', description='Compose modular .lime grammar files into a single parser file.', epilog='See docs/MODULE_FORMAT.md for the directive-based module specification.', ) parser.add_argument( 'files', metavar='FILE', nargs='*', help='Modular .lime files to compose') parser.add_argument( '-o', '--output', metavar='FILE', default=None, help='Write composed grammar to FILE (default: stdout)') parser.add_argument( '-v', '--verbose', action='store_true', help='Print module resolution order to stderr') parser.add_argument( '-n', '--dry-run', action='store_true', help='Validate and show resolution order without producing output') parser.add_argument( '--no-validate', action='store_true', help='Skip dependency validation') parser.add_argument( '--list-modules', action='store_true', help='List modules found in input files and exit') return parser def main(argv=None): parser = build_parser() args = parser.parse_args(argv) if not args.files: parser.print_help(sys.stderr) return 1 # Parse all input files modules = [] errors = [] for filepath in args.files: try: mod = parse_module_file(filepath) modules.append(mod) except (FileNotFoundError, SyntaxError, ValueError) as e: errors.append(str(e)) if errors: for err in errors: print("ERROR: %s" % err, file=sys.stderr) return 1 # List modules mode if args.list_modules: for mod in modules: deps = ', '.join([d[0] for d in mod.dependencies]) if mod.dependencies else '(none)' exports = ', '.join(mod.exports) if mod.exports else '(none)' print("%-20s v%-10s exports: %-30s depends: %s" % (mod.name, mod.version, exports, deps)) return 0 # Validate dependencies if not args.no_validate: dep_errors = validate_dependencies(modules) if dep_errors: for err in dep_errors: print("ERROR: %s" % err, file=sys.stderr) return 1 # Topological sort try: sorted_modules = topological_sort(modules) except ValueError as e: print("ERROR: %s" % e, file=sys.stderr) return 1 if args.verbose or args.dry_run: print("Module resolution order:", file=sys.stderr) for i, mod in enumerate(sorted_modules, 1): print(" %d. %s (%s)" % (i, mod.name, mod.source_file), file=sys.stderr) if args.dry_run: return 0 # Compose output output = compose(sorted_modules) if args.output: output_dir = os.path.dirname(args.output) if output_dir and not os.path.isdir(output_dir): print("ERROR: Output directory does not exist: %s" % output_dir, file=sys.stderr) return 1 with open(args.output, 'w') as f: f.write(output) if args.verbose: print("Wrote composed grammar to %s" % args.output, file=sys.stderr) else: sys.stdout.write(output) return 0 if __name__ == '__main__': sys.exit(main())