blob: 9017bc8d068adeafc9013b42615b233a393c2f4f [file] [log] [blame]
#!/usr/bin/env fuchsia-vendored-python
# Copyright 2025 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import os
import sys
""" A script for providing suggestions for fx if no commands are found.
This script is not intended to be run directly, rather it should be run as part
of the fx script when it cannot find a command to run. The script expects a list
of commands to be piped in which will be used as the list of commands that will
be checked. This is done to avoid argument limits but it will also lead to hangs
if the program is not called with piping stdin into the program.
The list of commands should be a newline separated list of either command names
or paths to commands that fx will exec.
"""
# This dictionary contains a list of known suggestions that have been moved or
# migrated to different tools. The key is the command that the user provided and
# the value is what the user will see instead.
WELL_KNOWN_SUGGESTIONS = {
"emu": "'fx emu' is no longer valid, use 'ffx emu' instead"
}
def calculate_dl_edit_distance(first: str, second: str) -> int:
"""Calculates the Damerau-Levenshtein distance between two strings.
We want to use Damerau-Levenshtein since it will treat transposition as a
single edit instead of 2 edits. This will help us better match simple typos
like a user typing 'buidl' instead of 'build'.
Note: we cannot use an external library here because `fx` runs before the
build system can realize dependencies.
Args:
- first: the first string to compare
- second: the second string to compare
Returns:
The edit distance between the two values.
"""
len1, len2 = len(first), len(second)
edits = {}
# Fill out edits matrix
for i in range(-1, len1 + 1):
edits[(i, -1)] = i + 1
for j in range(-1, len2 + 1):
edits[(-1, j)] = j + 1
for i in range(len1):
for j in range(len2):
edits[(i, j)] = min(
edits[(i - 1, j)] + 1, # deletion
edits[(i, j - 1)] + 1, # insertion
edits[(i - 1, j - 1)]
+ (0 if first[i] == second[j] else 1), # substitution
)
if (
i > 0
and j > 0
and first[i] == second[j - 1]
and first[i - 1] == second[j]
):
edits[(i, j)] = min(
edits[(i, j)], edits[(i - 2, j - 2)] + 1
) # transposition
return edits[(len1 - 1, len2 - 1)]
def closest_match(
cmd: str, commands: list[str], max_distance: int = 1
) -> str | None:
"""Find the closest matching command
Checks the list of commands and finds the closest match or None if no matches
satisfy the max_distance.
"""
closest_cmd = None
min_dist = float("inf")
for command in commands:
dist = calculate_dl_edit_distance(cmd, command)
if dist <= max_distance and dist < min_dist:
min_dist = dist
closest_cmd = command
return closest_cmd
def sanitize_commands(commands: str) -> list[str]:
"""Convert a string of commands to a list of commands"""
# Remove ".fx" and use the basename of the command to match what fx is looking for.
return [
os.path.basename(c.removesuffix(".fx"))
for c in commands.split()
if c != ""
]
def main() -> None:
commands = sanitize_commands(sys.stdin.read())
cmd = sys.argv[1]
suggestion: str | None = None
if cmd in WELL_KNOWN_SUGGESTIONS:
suggestion = WELL_KNOWN_SUGGESTIONS[cmd]
else:
match = closest_match(cmd, commands)
if match:
suggestion = "Command '{}' not found. Did you mean '{}'?".format(
cmd, match
)
if suggestion:
print(suggestion)
if __name__ == "__main__":
main()