wc.exe 功能实现

github 代码地址:https://github.com/1471104698/wc

1、题目描述

Word Count
1. 实现一个简单而完整的软件工具(源程序特征统计程序)。
2. 进行单元测试、回归测试、效能测试,在实现上述程序的过程中使用相关的工具。
3. 进行个人软件过程(PSP)的实践,逐步记录自己在每个软件工程环节花费的时间。

2、WC 项目要求

wc.exe 是一个常见的工具,它能统计文本文件的字符数、单词数和行数。这个项目要求写一个命令行程序,模仿已有wc.exe 的功能,并加以扩充,给出某程序设计语言源文件的字符数、单词数和行数。

实现一个统计程序,它能正确统计程序文件中的字符数、单词数、行数,以及还具备其他扩展功能,并能够快速地处理多个文件。
具体功能要求:
程序处理用户需求的模式为:

wc.exe [parameter] [file_name]

基本功能列表:

wc.exe -c file.c     //返回文件 file.c 的字符数

wc.exe -w file.c    //返回文件 file.c 的词的数目  

wc.exe -l file.c      //返回文件 file.c 的行数

扩展功能:
    -s   递归处理目录下符合条件的文件。
    -a   返回更复杂的数据(代码行 / 空行 / 注释行)。

空行:本行全部是空格或格式控制字符,如果包括代码,则只有不超过一个可显示的字符,例如“{”。

代码行:本行包括多于一个字符的代码。

注释行:本行不是代码行,并且本行包括注释。一个有趣的例子是有些程序员会在单字符后面加注释:

    } //注释
在这种情况下,这一行属于注释行。

[file_name]: 文件或目录名,可以处理一般通配符。

高级功能:

 -x 参数。这个参数单独使用。如果命令行有这个参数,则程序会显示图形界面,用户可以通过界面选取单个文件,程序就会显示文件的字符数、行数等全部统计信息。

需求举例:
  wc.exe -s -a *.c

返回当前目录及子目录中所有*.c 文件的代码行数、空行数、注释行数。

3、具体代码

①、构造方法

    //默认处理 .java 文件
    private String suffixName = ".java";

    /**
     * 构造方法
     * @param suffixName 需要处理的文件后缀名
     */
    public WcTest(String suffixName){
        this.suffixName = suffixName;
    }

    public WcTest(){
    }

②、输入函数,也是对外开放主函数

这里主要做 Scanner 输入,并且其中 的 opStrsIsOk() 方法是对输入的命令行进行验证是否可用,该方法这里不做展开

为了后续处理方便,因此要求 -s 必须在 -w 之类的指令之前输入

/**
     * 对外开放主函数
     */
    public void input(){
        Scanner scanner = new Scanner(System.in);
        do {
            System.out.println("命令行格式:wc [options] [filePath]");
            System.out.println("ps:如需 -s 递归处理当前目录及其子目录,请输入目录路径");
            System.out.println("退出输入:exit");
            String inStr = scanner.nextLine();
            if("exit".equals(inStr)){
                break;
            }

            String[] opStrs = inStr.split("\\s+");
            /*
            wc -c F:\idea-workspace\ruangong1\src\cn\oy\test\Test.java
            wc -s -c F:\idea-workspace\ruangong1\src\cn\oy\test

            wc -c F:\idea-workspace\ruangong1\src\cn\oy\test\wc\WcTest.java

            wc -c F:\idea-workspace\ruangong1\src\cn\oy\test\wc\??????.java

             */
            //前置检查:判断操作数组是否符合标准
            if(!StringUtil.opStrsIsOk(opStrs)){
                //异常处理,这里用输出代替
                System.out.println("指令输入有误,请重新输入!!!");
                continue;
            }

            try {
                if(!StringConstant.RECURSION_CHARACTER.equals(opStrs[1])){
                    op(opStrs);
                }else {
                    recHandle(opStrs);
                }
            } catch (IOException e) {
                System.out.println("出现未知错误!!!");
            }
            System.out.println("***********************************");
            System.out.println("***********************************");
        } while (true);
    }

③、文件操作函数,根据传进来的操作指令进行不同的处理

ps:这里使用的是 switch 对应不同的情况,扩展性较低,其实我是想建一个接口,然后每个处理都建一个类,继承该接口,然后使用 map 将 操作指令 和 类实例进行绑定,

之后就可以通过操作指令直接获取实例进行处理,这样后续新功能只需要添加一个类继承接口即可,无需动这里的代码,不过这里就没必要弄了

 /**
     * 文件操作函数
     * @param opStrs 操作字符串数组
     * @throws IOException
     */
    private void op(String[] opStrs) throws IOException {

        int pos = opStrs.length - 1;

        //如果文件类型不符合
        if(!fileIsOk(opStrs[pos])){
            //异常处理,这里用输出代替
            System.out.println("文件格式错误");
            return;
        }

        File file = new File(opStrs[pos]);

        //文件不存在
        if(!file.exists()){
            //判断是否满足通配符匹配,如果是从 -s 过来的,文件肯定是存在的,因此不会进入到这里的匹配阶段
            if(!matchFileHandle(opStrs, file)){
                //异常处理,这里用输出代替
                System.out.println("文件不存在");
            }
            return;
        }

        System.out.println("文件名:" + file.getName());

        BufferedReader reader = null;
        // 如果是 -s 递归进来的,如 wc -s -c -a file.c ,那么就是 从 opStrs[2] 开始,
        // 如果是普通操作,如 -a、-c 那么从 opStrs[1] 开始
        for(int i = StringConstant.RECURSION_CHARACTER.equals(opStrs[1]) ? 2 : 1; i < pos; i++){
            /*
            获取文件字符输出流
            为什么需要写在这里? 而不是在 for 循环外面?
            因为如果写在外面,那么所有操作都使用一个 reader,而第一个操作会持续 readLine() 将字符全部读完,导致其他操作的 readLine() 为空
            因此,每一个操作都需要 重新获取一次 字符输出流
             */
            reader = StreamUtil.getReaderStream(file);
            switch (opStrs[i]){
                case "-c":
                    System.out.println("字符数:" + readFileCharacter(reader)); break;
                case "-w":
                    System.out.println("单词数:" + readFileWord(reader)); break;
                case "-l":
                    System.out.println("行数:" + readFileLine(reader)); break;
                case "-a":
                    readFileSpecialLine(reader);
                default: break;
            }
        }
        //关闭流
        StreamUtil.closeStream(reader);
    }

④、递归处理文件

先判断目录是否存在以及是否是文件,在通过 listFiles() 方法获取当前目录下的文件及目录,进行遍历,如果是文件,那么存储起来,如果是目录,那么 继续 dfs

目录处理完毕,那么就对文件统一进行处理,在这里为了保证进入上面的 文件操作函数 op() 是满足条件的文件,同时也是为了能够防止当前目录下没有满足条件的文件而输出那句 "当前目录:" 导致输出不可观,因此边遍历边进行判断

同时处理文件也很简单,直接将 操作指令数组的最后一个位置 的文件路径替换为当前文件的 path 即可

/**
     * 递归处理目录及子目录下的文件
     *
     * 路径下的 / 和 \\ 是等价的
     * @param opStrs 操作字符串
     */
    //wc -s -c file.c
    private void recHandle(String[] opStrs) throws IOException {
        int pos = opStrs.length - 1;
        //记录当前目录位置,防止因为后续修改而丢失
        String curCatalog = opStrs[pos];

        File file = new File(opStrs[pos]);
        //file.isFile() 能判断是否是文件
        if(!file.exists() || file.isFile()){
            //异常处理
            System.out.println("目录错误 或 所选择路径不是一个目录");
        }
        //获取子目录
        File[] files = file.listFiles();
        //判空
        if(files != null){
            List<File> fileList = new ArrayList<>();
            for(File f : files){
                //如果是文件那么将文件先存储起来
                if(f.isFile()){
                    fileList.add(f);
                }else{
                    //将最后文件目录修改为子目录
                    opStrs[pos] = f.getPath();
                    recHandle(opStrs);
                }
            }
            boolean flag = false;
            //统一处理文件
            for(File f : fileList){
                //判断是否是 .java 文件,如果文件类型不符合,不进行处理
                if(!fileIsOk(f.getPath())){
                    continue;
                }
                //该目录下有满足条件,即存在 java 文件的话,才输出目录,并且记录是否已经输出,保证只输出一次
                if(!flag){
                    System.out.println("当前目录:" + curCatalog);
                }
                flag = true;
                opStrs[pos] = f.getPath();
                op(opStrs);
            }
        }
    }

④、基本函数,没什么可说的

 /**
     * 获取行数
     * @param reader
     * @return
     * @throws IOException
     */
    private int readFileLine(BufferedReader reader) throws IOException {
        int countLine = 0;
        while(reader.readLine() != null){
            countLine++;
        }
        return countLine;
    }

    /**
     * 获取字符数
     * @param reader
     * @return
     * @throws IOException
     */
    private int readFileCharacter(BufferedReader reader) throws IOException {
        int countCharacter = 0;
        String str = "";
        while((str = reader.readLine()) != null){
            countCharacter += str.length();
        }
        return countCharacter;
    }

    /**
     * 获取单词数
     * @param reader
     * @return
     * @throws IOException
     */
    private int readFileWord(BufferedReader reader) throws IOException {
        //使用正则进行分割:空格 Tab { } ;: ~ ! ? ^ % + - * / | & >> >>> << <<< [ ] ( ) \\
        int countWord = 0;
        String str = "";
        while((str = reader.readLine()) != null){
            //这里只使用部分符号,还有更多符号没有进行添加
            countWord += str.split("\\s+|\\(|\\)|,|\\.|\\:|\\{|\\}|\\-|\\*|\\+|;|\\?|\\/|\\\\|/").length;
        }
        return countWord;
    }


    /**
     * 读取特殊行
     * @param reader
     */
    private void readFileSpecialLine(BufferedReader reader) throws IOException {    //
        /*
        注释行的情况:
        单行注释:开头: 1、// 2、空格 + // 3、单个字符 + //
        多行注释:开头:/* ,使用 flag 进行记录接下来内容是否属于该注释行的注释内容,直到找到 * / 为止

        空行:1、空格 2、除空格外的,只有 1 个字符

        代码行:不包括空格,至少有 2 个字符
         */
        List<String> noteList = Arrays.asList("//", "/*", "*/");
        String oneNote = "//";
        String moreNoteStart = "/*";
        String moreNoteEnd = "*/";

        int noteLine = 0;
        int trimLine = 0;
        int codeLine = 0;
        boolean flag = false;
        String str = "";
        while ((str = reader.readLine()) != null){
            str = str.trim();
            if(str.length() < 2){  //空行,0 个字符或 1 个字符
                trimLine++;
            }else if(oneNote.equals(str.substring(0, 2)) || str.length() > 2 && oneNote.equals(str.substring(1, 3))){            //单行注释
                noteLine++;
            }else if(moreNoteStart.equals(str.substring(0, 2))){    //多行注释开头
                noteLine++;
                //判断结尾标识符 */ 是否在当前行
                if(!str.contains(moreNoteEnd)){
                    flag = true;
                }
            }else if(flag){ //是否仍是注释的范围
                noteLine++;
            }else if(str.contains(moreNoteEnd)){    //该行是否是注释的结尾
                noteLine++;
                flag = false;
            }else{
                codeLine++;
            }
        }
        //wc -a F:\idea-workspace\ruangong1\src\cn\oy\test\wc\T.java
        System.out.println("注释行:" + noteLine);
        System.out.println("空行:" + trimLine);
        System.out.println("代码行:" + codeLine);
    }

⑤、通配符匹配,这里使用动态规划进行文件名的匹配

具体过程:

1、第一个 函数 matchFileHandle() 是通配符匹配方法的 入口,里面最开始调用 isExistMatch() 判断文件名是否存在通配符 "*"、"?",如果不存在,那么可以直接返回了

2、获取当前文件的父目录下的所有文件,比如 当前文件是 F://a//b//c//x.txt,那么父目录就是 F://a//b//c,然后获取父目录下的所有文件,使用 isMatch() 方法,利用动态规划方法进行匹配,如果匹配成功,那么返回 true,证明该文件需要处理,否则跳过

    /**
     * 查找目录下的通配符匹配文件,不包括递归,只遍历跟当前文件同目录的文件
     * @param file
     */
    private boolean matchFileHandle(String[] opStrs, File file) throws IOException {
        //得到当前文件名称
        String fileName = file.getName();
        //判断是否存在匹配通配符
        if(!isExistMatch(fileName)){
            return false;
        }
        //得到父路径
        File parentFile = file.getParentFile();
        //遍历父路径下的所有文件
        File[] files = parentFile.listFiles();
        if (files != null) {
            for(File f : files){
                //判断是否匹配
                if(isMatch(f.getName(), fileName)){
                    //修改文件路径
                    opStrs[opStrs.length - 1] = f.getPath();
                    op(opStrs);
                }
            }
        }
        return true;
    }

    /**
     * 判断文件名是否存在 ? 或 * 通配符
     * @param fileName
     * @return
     */
    private boolean isExistMatch(String fileName){
        return fileName.contains("?") || fileName.contains("*");
    }

    /**
     * 通配符匹配    ? 可表示任意单个字符, * 可以表示任意单个或多个字符,也可以表示空串
     * s1 和 s2 是否匹配
     * @param s
     * @param p
     * @return
     */
    private boolean isMatch(String s, String p){
        /*
        输入:s = "aa"      p = "a"
        输出: false
        解释: "a" 无法匹配 "aa" 整个字符串。

        输入:s = "aa"    p = "*"
        输出: true
        解释: '*' 可以匹配任意字符串。

        使用动态规划
        dp[i][j] 表示 s1 的前 i 个字符是否能被 s2 的前 j 个字符进行匹配

           ""   a   d   c   e   b
        ""  T   F   F   F   F   F
        *   T   T   T   T   T   T
        *   T   T   T   T   T   T
        a   F   T   F   F   F   F
        *   F   T   T   T   T   T
        b   F   F   F   F   F   T
         */

        char[] ss = s.toCharArray();
        char[] ps = p.toCharArray();

        boolean[][] dp = new boolean[ss.length + 1][ps.length + 1];

        //当 ss 和 ps 都只有 0 个字符的时候,那么匹配
        dp[0][0] = true;

        //当 ss 为空串时,那么只有 ps 全部为 * 时才可以进行匹配(? 必须匹配单个字符,不能匹配空串)
        for(int i = 1; i <= ps.length; i++){
            dp[0][i] = ps[i - 1] == '*' && dp[0][i - 1];
        }

        for(int i = 1; i <= ss.length; i++){
            for(int j = 1; j <= ps.length; j++){
                if(ps[j - 1] == '?' || ps[j - 1] == ss[i - 1]){
                    //如果 ps 的当前字符为 ? 或者 ss 和 ps 当前字符相同,那么直接看两者上一个字符的匹配情况
                    dp[i][j] = dp[i - 1][j - 1];
                }else if(ps[j - 1] == '*'){
                    //如果 ps 当前字符为 *,那么有两种情况,匹配 ss 当前字符(该选择类似完全背包问题) 或者不匹配,即匹配空串
                    dp[i][j] = dp[i - 1][j] || dp[i][j - 1];
                }
            }
        }
        return dp[ss.length][ps.length];
    }

⑥、工具类之一,也是处理操作指令的方法

一看就懂

public class StringUtil {

    static List<String> opList = Arrays.asList("-a", "-c", "-l", "-w", "-s");

    /**
     * 判断操作字符串数组是否符合标准
     * @param opStrs
     * @return 当不符合标准时返回 false
     */

    public static boolean opStrsIsOk(String[] opStrs){

        //wc -c F:\idea-workspace\ruangong1\src\cn\oy\test\Test.java

        int len = opStrs.length;

        //1、操作数组长度不满足要求
        if(len < StringConstant.OP_COMMON_MIN_LEN){
            return false;
        }

        //2、首字符串不符合要求,即不为 "wc"
        if(!StringConstant.HEAD_CHARACTER.equals(opStrs[0])){
            return false;
        }

        //3、最后一个元素不是目录而仍然是操作数
        if(opList.contains(opStrs[len - 1])){
            return false;
        }

        //4、递归标识符位置检查 以及 指令查重 、中间是否是操作数检查
        if(!checkOp(opStrs)){
            return false;
        }

        return true;

    }

    /**
     * 指令查重 以及 递归标识符 -s 可用性检查
     *
     * @param opStrs
     * @return 指令有误返回 false
     */
    private static boolean checkOp(String[] opStrs){
        //是否出现其他操作符
        boolean com_flag = false;
        //是否出现过递归标识符
        boolean rec_flag = false;
        Set<String> set  = new HashSet<>();
        for(int i = 1; i < opStrs.length - 1; i++){
            //出现重复指令
            if(!set.add(opStrs[i])){
                return false;
            }
            //出现递归标识符 -s
            if(StringConstant.RECURSION_CHARACTER.equals(opStrs[i])){
                //在之前就出现了普通操作数,那么顺序不对
                if(com_flag){
                    return false;
                }else{
                    rec_flag = true;
                }
            }else if(opList.contains(opStrs[i])){  //普通操作数
                com_flag = true;
            }else {     //如果什么都不是,即不是 -s、-c、-a 等指令
                return false;
            }
        }
        //两个同时出现(在此已经保证了顺序的正确性,因为上面对顺序不正确的已经做了处理) 或者 只出现普通操作数
        return rec_flag && com_flag || !rec_flag && com_flag;
    }


    /**
     * 判断是否是需要处理的文件类型
     * @param fileName
     * @return
     */
    public static boolean fileFilter(String fileName, String suffixName){
        return fileName.endsWith(suffixName);
    }
}

4、测试截图

测试类

package cn.oy.test;

import cn.oy.test.utils.StringUtil;
import cn.oy.test.wc.WcTest;

import java.io.File;
import java.io.IOException;
import java.util.Scanner;

/**
 * @author 蒜头王八
 * @project: ruangong1
 * @Description:
 * @Date 2020/3/14 22:43
 */
public class Test {
    /**
     * 测试函数
     * @param args
     */
    public static void main(String[] args) throws IOException {
        WcTest wcTest = new WcTest();
        wcTest.input();
        // File file = new File("F:\\idea-workspace\\ruangong1\\src\\cn\\oy\\test\\wc\\WcTest.java");
        // System.out.println(file.getParentFile());
        // System.out.println("//".split("//").length);
    }
}

1、基本函数测试

 

 2、递归处理

 3、通配符处理

测试类为上述 Test.java

5、PSP表格

PSP Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 15 20
· Estimate · 估计这个任务需要多少时间 15 20
Development 开发 200 250
· Analysis · 需求分析 (包括学习新技术) 30 60
· Design Spec · 生成设计文档 10 10
· Design Review · 设计复审 (和同事审核设计文档) 5 5
· Coding Standard · 代码规范 (为目前的开发制定合适的规范) 10 5
· Design · 具体设计 5 5
· Coding · 具体编码 30 15
· Code Review · 代码复审 10 5
· Test · 测试(自我测试,修改代码,提交修改) 20 10
Reporting 报告 30 60
· Test Report · 测试报告 10 20
· Size Measurement · 计算工作量 10 10
· Postmortem & Process Improvement Plan · 事后总结, 并提出过程改进计划 10 20
合计   195 260

6、项目总结

这是我第一次练习做项目,虽然功能尚不完善,但是仍还是满足基本需求,总结如下:

一、理论到实践需要一步步走,不是一步登天。

二、 PSP 表格可以让我们对自己能力有所了解,并规范自己。

三、此次练习我也发现了自己的不足,会努力进行改进

这是我第一次做完成的项目,虽然部分功能未完成,但也满足基本需求。这也是我第一次使用软件工程的方式完成项目。对此,有以下总结:

猜你喜欢

转载自www.cnblogs.com/suantouwangba/p/12541507.html